Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CURRENT_SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,12 @@ Current status:
- string literals are handled specially and flow through the current backend/runtime model
- `len(str)` is supported
- `str` currently behaves as an immutable view-like text type
- explicit casts between `str` and readonly `[]u8` / `[]char` are lowered through runtime helpers
- `str as []u8` produces a readonly byte view with the same `{ptr, len}` backing storage
- `[]u8 as str` produces a trusted text view with the same `{ptr, len}` backing storage and does not validate UTF-8; downstream `str` operations assume the bytes are valid UTF-8
- explicit copy/view materialization helpers (`str_bytes`, `bytes_str`, `str_chars`) remain available
- direct allocating casts such as `str as []char` and `[]char as str` are rejected
- numeric formatting and `[]char` encoding use `to_str<T: Stringable>(value)`, not `as str`
- types that provide `fn Name::String(self) -> str` can use `value as str`
- casts from `str` to mutable `[]mut u8` / `[]mut char` are rejected
- the final owned mutable text type is not part of the current core language surface

Expand Down
31 changes: 21 additions & 10 deletions ferret_libs_dev/global.fer
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// `str` is the built-in immutable text view type: an opaque fat pointer
// `{ data *u8, len usize }`.
// Assigning or passing `str` copies only this small view, not the backing bytes.
// The helper declarations below use `*str` as their current FFI ABI shape.
// The helper declarations below use references to pass view descriptors across
// the runtime FFI boundary.
// String literals have type `str`. Use str_data(s) and str_len(s) to extract
// the raw fields at C FFI boundaries.
//
Expand Down Expand Up @@ -64,6 +65,14 @@ fn set<K: Comparable, V>(value: &mut map[K]V, key: K, item: V) -> ?V;
#[extern]
fn compile_error(message: str) -> void;

// Converts Stringable values to text when formatting or encoding is required.
// Numeric types, str, []char, and types with String(self) -> str satisfy Stringable.
// Use `as` for zero-copy reshapes.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The to_str docs here recommend using as for zero-copy reshapes, but don’t mention that []u8 as str is a trusted view that does not validate UTF-8 (per CURRENT_SEMANTICS). Consider adding an explicit warning in this comment block so callers don’t assume the cast is validating/safe for arbitrary bytes.

Suggested change
// Use `as` for zero-copy reshapes.
// Use `as` for zero-copy reshapes.
// Warning: `[]u8 as str` creates a trusted view and does not validate UTF-8.
// Cast arbitrary bytes only when they are already known to be valid UTF-8.

Copilot uses AI. Check for mistakes.
// Warning: `[]u8 as str` creates a trusted view and does not validate UTF-8.
// Cast arbitrary bytes only when they are already known to be valid UTF-8.
#[extern]
fn to_str<T: Stringable>(value: T) -> str;

// ---------------------------------------------------------------------------
// str field accessors
//
Expand All @@ -74,34 +83,36 @@ fn compile_error(message: str) -> void;
// Returns the raw byte pointer that backs s.
// Valid only as long as s is live. Do not store without care.
#[extern]
fn str_data(s: *str) -> ^void;
fn str_data(s: &str) -> ^void;

// Returns the byte length of s (number of UTF-8 code units / bytes).
#[extern]
fn str_len(s: *str) -> usize;
fn str_len(s: &str) -> usize;

// Copies the UTF-8 bytes of s into a fresh []u8 slice.
// Use `s as []u8` when a readonly byte view is enough.
#[extern]
fn str_bytes(s: *str) -> []u8;
fn str_bytes(s: &str) -> []u8;

// Copies the bytes of a []u8 slice into a fresh str value.
// This is explicit because the operation allocates and trusts caller UTF-8.
#[extern]
fn bytes_str(bytes: *[]u8) -> str;
fn bytes_str(bytes: &[]u8) -> str;

// Decodes the UTF-8 bytes of s into a fresh []char slice.
#[extern]
fn str_chars(s: *str) -> []char;
fn str_chars(s: &str) -> []char;

// Encodes a []char slice into a fresh UTF-8 str value.
// Runtime target used by to_str([]char).
#[extern]
fn chars_str(chars: *[]char) -> str;
fn chars_str(chars: &[]char) -> str;

// Copies s into a newly allocated NUL-terminated C string.
// The returned pointer can be passed to C APIs and freed with free().
#[extern]
fn str_cstr(s: *str) -> ^void;
fn str_cstr(s: &str) -> ^void;

// Internal helpers used by numeric-to-string cast lowering.
// Runtime targets used by to_str(numeric).
#[extern]
fn i64_str(value: i64) -> str;

Expand Down
4 changes: 2 additions & 2 deletions ferret_libs_dev/std/net/http.fer
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ fn write_response(conn: &mut tcp::Conn, status: u16, content_type: str, body: st
total = (total as i64 + (conn.Write("HTTP/1.1 ") catch |err| {
return err
}) as i64) as usize
total = (total as i64 + (conn.Write(status as str) catch |err| {
total = (total as i64 + (conn.Write(to_str(status)) catch |err| {
return err
}) as i64) as usize
total = (total as i64 + (conn.Write(" ") catch |err| {
Expand All @@ -340,7 +340,7 @@ fn write_response(conn: &mut tcp::Conn, status: u16, content_type: str, body: st
total = (total as i64 + (conn.Write("\r\nContent-Length: ") catch |err| {
return err
}) as i64) as usize
total = (total as i64 + (conn.Write(len(body as []u8) as str) catch |err| {
total = (total as i64 + (conn.Write(to_str(len(body as []u8))) catch |err| {
return err
}) as i64) as usize
total = (total as i64 + (conn.Write("\r\nContent-Type: ") catch |err| {
Expand Down
2 changes: 1 addition & 1 deletion internal/analysis/semantics/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ func (r *resolver) reportInvalidType(loc source.Location, name string) {
}

func isPredeclaredType(name string) bool {
return tokens.IsBuiltinType(name) || name == "Type" || name == "Comparable"
return tokens.IsBuiltinType(name) || name == "Type" || name == "Comparable" || name == "Stringable"
}

func (r *resolver) findOwnerModuleForSymbol(sym *symbols.Symbol) *context.Module {
Expand Down
66 changes: 41 additions & 25 deletions internal/analysis/semantics/typechecker/syntax_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,46 +16,44 @@ func (c *checker) typeFromSyntax(mod *context.Module, expr ast.TypeExpr) typeinf
case nil:
return nil
case *ast.NamedType:
if len(t.TypeArgs) > 0 && len(t.Path) == 1 {
if tokens.IsBuiltinType(t.Path[0]) {
if name, ok := unqualifiedTypeName(t); ok {
if len(t.TypeArgs) > 0 && tokens.IsBuiltinType(name) {
loc := t.Loc()
c.ctx.Diagnostics.Add(
diagnostics.NewError(fmt.Sprintf("type %q is not generic", t.Path[0])).
diagnostics.NewError(fmt.Sprintf("type %q is not generic", name)).
WithCode(diagnostics.ErrTypeMismatch).
WithPrimaryLabel(&loc, "remove type arguments from this non-generic type"),
)
return typeinfo.InvalidType{}
}
if _, ok := c.lookupTypeParam(t.Path[0]); ok {
loc := t.Loc()
c.ctx.Diagnostics.Add(
diagnostics.NewError(fmt.Sprintf("type parameter %q cannot take type arguments", t.Path[0])).
WithCode(diagnostics.ErrTypeMismatch).
WithPrimaryLabel(&loc, "remove type arguments from this type parameter"),
)
return typeinfo.InvalidType{}
}
}
if len(t.Path) == 1 && tokens.IsBuiltinType(t.Path[0]) {
if t.Path[0] == "str" {
return &typeinfo.StringType{}
if tokens.IsBuiltinType(name) {
if name == "str" {
return &typeinfo.StringType{}
}
return &typeinfo.BuiltinType{Name: name}
}
return &typeinfo.BuiltinType{Name: t.Path[0]}
}
if len(t.Path) == 1 {
if t.Path[0] == "Comparable" {
if constraint := predeclaredConstraintType(name); constraint != nil {
if len(t.TypeArgs) > 0 {
loc := t.Loc()
c.ctx.Diagnostics.Add(
diagnostics.NewError(`type "Comparable" is not generic`).
diagnostics.NewError(fmt.Sprintf("type %q is not generic", name)).
WithCode(diagnostics.ErrTypeMismatch).
WithPrimaryLabel(&loc, "remove type arguments from this constraint"),
)
return typeinfo.InvalidType{}
}
return &typeinfo.ComparableConstraint{}
return constraint
}
if typeParam, ok := c.lookupTypeParam(t.Path[0]); ok {
if typeParam, ok := c.lookupTypeParam(name); ok {
if len(t.TypeArgs) > 0 {
loc := t.Loc()
c.ctx.Diagnostics.Add(
diagnostics.NewError(fmt.Sprintf("type parameter %q cannot take type arguments", name)).
WithCode(diagnostics.ErrTypeMismatch).
WithPrimaryLabel(&loc, "remove type arguments from this type parameter"),
)
return typeinfo.InvalidType{}
}
return typeParam
}
}
Expand Down Expand Up @@ -249,6 +247,24 @@ func (c *checker) typeFromSyntax(mod *context.Module, expr ast.TypeExpr) typeinf
}
}

func unqualifiedTypeName(t *ast.NamedType) (string, bool) {
if t == nil || len(t.Path) != 1 {
return "", false
}
return t.Path[0], true
}

func predeclaredConstraintType(name string) typeinfo.Type {
switch name {
case "Comparable":
return &typeinfo.ComparableConstraint{}
case "Stringable":
return &typeinfo.StringableConstraint{}
default:
return nil
}
}

func (c *checker) selectedUnionMemberTarget(mod *context.Module, expr ast.TypeExpr) (typeinfo.Type, typeinfo.Type, bool) {
named, ok := expr.(*ast.NamedType)
if !ok || named == nil {
Expand Down Expand Up @@ -284,10 +300,10 @@ func (c *checker) lookupNamedUnionMemberType(mod *context.Module, unionDecl *ast
}
for _, member := range unionDecl.Members {
named, ok := member.(*ast.NamedType)
if !ok || named == nil || len(named.Path) != 1 {
if !ok {
continue
}
if named.Path[0] == name {
if memberName, ok := unqualifiedTypeName(named); ok && memberName == name {
return c.typeFromSyntax(mod, member), true
}
}
Expand Down
30 changes: 25 additions & 5 deletions internal/analysis/semantics/typechecker/typechecker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2115,6 +2115,13 @@ func (c *checker) checkTypeParamConstraintsAt(loc source.Location, params []*typ
c.reportTypeMismatch(loc, constraint, actual)
continue
}
if _, ok := constraint.(*typeinfo.StringableConstraint); ok {
if c.isStringableType(actual) {
continue
}
c.reportTypeMismatch(loc, constraint, actual)
continue
}
if c.assignable(constraint, actual) {
continue
}
Expand All @@ -2126,6 +2133,17 @@ func (c *checker) checkTypeParamConstraintsAt(loc source.Location, params []*typ
}
}

func (c *checker) isStringableType(typ typeinfo.Type) bool {
base := c.underlying(typ)
if approx, ok := base.(*typeinfo.ApproxType); ok && approx != nil {
return c.isStringableType(approx.Inner)
}
return c.isStringType(typ) ||
typeinfo.IsNumeric(base) ||
c.isCharSliceType(typ) ||
c.isStringMethodCoercion(&typeinfo.StringType{}, typ)
}
Comment on lines +2136 to +2145

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isStringableType doesn’t unwrap ApproxType ("~T") before checking IsNumeric, so values typed as e.g. ~i32/~u64 won’t satisfy the new Stringable constraint even though Comparable and other type logic treat ApproxType as a transparent wrapper. Consider handling *typeinfo.ApproxType (and delegating to its Inner) so to_str works consistently for approximate numeric types.

Copilot uses AI. Check for mistakes.

func (c *checker) checkInstantiatedGenericRequirements(call *ast.CallExpr, callee ast.Node, bindings map[*typeinfo.TypeParam]typeinfo.Type) bool {
sym := c.resolvedFunctionSymbol(callee)
if sym == nil {
Expand Down Expand Up @@ -3070,14 +3088,16 @@ func (c *checker) isExplicitEnumCast(target, source typeinfo.Type) bool {
}

func (c *checker) isExplicitStringCast(target, source typeinfo.Type) bool {
if c.isStringType(target) && (c.isByteSliceType(source) || c.isCharSliceType(source) || typeinfo.IsNumeric(source)) {
return true
}
if c.isStringMethodCoercion(target, source) {
return true
}
if c.isStringType(source) && (c.isByteSliceType(target) || c.isCharSliceType(target)) {
return true
if c.isStringType(source) {
targetSlice, ok := c.underlying(target).(*typeinfo.SliceType)
return ok && targetSlice != nil && !targetSlice.Mutable && typeinfo.IsBuiltinNamed(targetSlice.Inner, "u8")
}
if c.isStringType(target) {
sourceSlice, ok := c.underlying(source).(*typeinfo.SliceType)
return ok && sourceSlice != nil && !sourceSlice.Mutable && typeinfo.IsBuiltinNamed(sourceSlice.Inner, "u8")
}
return false
}
Expand Down
Loading
Loading