diff --git a/compiler/air/lower.go b/compiler/air/lower.go index a90708d3..07aebef1 100644 --- a/compiler/air/lower.go +++ b/compiler/air/lower.go @@ -437,8 +437,9 @@ func (l *lowerer) declareFunction(module ModuleID, def *checker.FunctionDef) (Fu Return: returnType, ReturnReference: returnReference, }, - IsTest: def.IsTest, - Private: def.Private, + IsTest: def.IsTest, + Private: def.Private, + ForeignABI: def.ForeignABI, }) l.program.Modules[module].Functions = appendUniqueFunction(l.program.Modules[module].Functions, id) if def.Name == "main" { @@ -484,12 +485,13 @@ func (l *lowerer) declareFunctionSpecializationWithSignatureAndGenericKey(module id := FunctionID(len(l.program.Functions)) l.functions[key] = id l.program.Functions = append(l.program.Functions, Function{ - ID: id, - Module: module, - Name: def.Name, - Signature: signature, - IsTest: def.IsTest, - Private: def.Private, + ID: id, + Module: module, + Name: def.Name, + Signature: signature, + IsTest: def.IsTest, + Private: def.Private, + ForeignABI: def.ForeignABI, }) l.program.Modules[module].Functions = appendUniqueFunction(l.program.Modules[module].Functions, id) return id, nil @@ -573,6 +575,7 @@ func (l *lowerer) declareGenericFunctionDef(module ModuleID, callDef *checker.Fu TypeParams: goParams, IsTest: def.IsTest, Private: def.Private, + ForeignABI: def.ForeignABI, }) l.program.Modules[module].Functions = appendUniqueFunction(l.program.Modules[module].Functions, id) typeVars := make(map[string]TypeID, len(params)) @@ -1787,6 +1790,7 @@ func (l *lowerer) declareMethodFunction(module ModuleID, owner checker.Type, tra Name: owner.String() + "." + traitName + "." + def.Name, Receiver: ownerType, MethodName: def.Name, + ForeignABI: def.ForeignABI, Signature: Signature{ Params: params, Return: returnType, @@ -1862,6 +1866,7 @@ func (l *lowerer) declareInstanceMethodFunction(module ModuleID, ownerName strin Name: ownerName + "." + def.Name, Receiver: ownerType, MethodName: def.Name, + ForeignABI: def.ForeignABI, Signature: signature, }) l.program.Modules[module].Functions = appendUniqueFunction(l.program.Modules[module].Functions, id) @@ -1915,6 +1920,7 @@ func (fl *functionLowerer) declareInstanceMethodFunction(module ModuleID, ownerN Name: ownerName + "." + def.Name, Receiver: ownerType, MethodName: def.Name, + ForeignABI: def.ForeignABI, Signature: signature, }) fl.l.program.Modules[module].Functions = appendUniqueFunction(fl.l.program.Modules[module].Functions, id) @@ -2054,6 +2060,7 @@ func (fl *functionLowerer) declareGenericInstanceMethodFunction(module ModuleID, Name: structDef.Name + "." + callDef.Name, Receiver: recvType, MethodName: callDef.Name, + ForeignABI: orig.ForeignABI, Signature: signature, TypeParams: paramNames, }) diff --git a/compiler/air/types.go b/compiler/air/types.go index 64e697e1..3a2497be 100644 --- a/compiler/air/types.go +++ b/compiler/air/types.go @@ -56,6 +56,9 @@ type Function struct { IsTest bool IsScript bool Private bool + // ForeignABI preserves an exact foreign Go method signature. Mutable + // descriptor parameters remain value-shaped at this boundary. + ForeignABI bool // TypeParams names the generic parameters for a generic function definition // (ADR 0031). When set, the function is emitted as `func Name[T any](...)` diff --git a/compiler/checker/checker.go b/compiler/checker/checker.go index b2699ca0..f302c96a 100644 --- a/compiler/checker/checker.go +++ b/compiler/checker/checker.go @@ -239,6 +239,7 @@ func derefTypeSeen(t Type, seen map[Type]bool) Type { Parameters: newParams, ReturnType: derefReturnType, ForeignResultShape: typ.ForeignResultShape, + ForeignABI: typ.ForeignABI, InferReturnTypeFromBody: typ.InferReturnTypeFromBody, Body: typ.Body, Mutates: typ.Mutates, @@ -391,6 +392,7 @@ type Checker struct { spans *SpanIndex nextCallInferenceID uint64 expectedCallExpectation *typeExpectation + foreignABIFunctions map[*parse.FunctionDeclaration]bool moduleFiles map[string]string } @@ -415,8 +417,9 @@ func New(filePath string, input *parse.Program, moduleResolver *ModuleResolver, StructMethods: map[MethodOwner]map[string]*FunctionDef{}, ForeignInterfaceImpls: map[MethodOwner][]*ForeignType{}, }, - scope: &rootScope, - goTypesContext: gotypes.NewContext(), + scope: &rootScope, + goTypesContext: gotypes.NewContext(), + foreignABIFunctions: map[*parse.FunctionDeclaration]bool{}, } if checkOptions.RecordSpans { c.spans = &SpanIndex{} @@ -1997,7 +2000,7 @@ func (c *Checker) checkForeignInterfaceImplementation(s *parse.TraitImplementati c.addTypeMismatch(expectedType, paramType, param.GetLocation()) valid = false } - if paramMutable && mutableParamNeedsGoPointer(paramType) { + if paramMutable && mutableForeignParamChangesGoABI(paramType) { legacy := fmt.Sprintf("Go interface method '%s' parameter '%s' cannot be mutable because it would change the Go ABI", method.Name, param.Name) c.addDiagnostic(implementationParameterMutabilityDiagnostic{ Method: method.Name, Parameter: param.Name, ExpectedMutable: false, Span: c.sourceSpan(param.GetLocation()), LegacyMessage: legacy, @@ -2031,6 +2034,7 @@ func (c *Checker) checkForeignInterfaceImplementation(s *parse.TraitImplementati continue } c.pushMethodGenericAllowlist(receiverGenerics) + c.foreignABIFunctions[&method] = true fnDef := c.checkFunction(&method, func() { c.scope.add(s.Receiver.Name, targetType, method.Mutates) }, receiverGenerics...) @@ -2048,6 +2052,7 @@ func (c *Checker) checkForeignInterfaceImplementation(s *parse.TraitImplementati } fnDef.Receiver = s.Receiver.Name fnDef.Mutates = method.Mutates + fnDef.ForeignABI = true fnDef.Name = goMethodNameToArdName(interfaceMethodName) if _, exists := c.structMethod(targetType, fnDef.Name); exists { validImpl = false @@ -2102,12 +2107,22 @@ func (c *Checker) structImplementsForeignInterface(def *StructDef, iface *Foreig // callee mutating the temporary is sound. Idiomatic Go passes container // literals to such parameters directly. func freshContainerSatisfiesMutable(paramType Type, arg Expression) bool { - if mutableParamNeedsGoPointer(paramType) { - return false - } + base, _ := mutableRefBase(paramType) switch arg.(type) { - case *ListLiteral, *MapLiteral: - return true + case *ListLiteral: + _, ok := base.(*List) + if ok { + return true + } + foreign, ok := base.(*ForeignType) + return ok && foreign.Elem != nil + case *MapLiteral: + _, ok := base.(*Map) + if ok { + return true + } + foreign, ok := base.(*ForeignType) + return ok && foreign.MapKey != nil && foreign.MapValue != nil } return false } @@ -2115,7 +2130,7 @@ func freshContainerSatisfiesMutable(paramType Type, arg Expression) bool { func mutableParamNeedsGoPointer(t Type) bool { base, _ := mutableRefBase(t) switch typ := base.(type) { - case *List, *Map, *Chan, *Receiver, *Sender: + case *Map, *Chan, *Receiver, *Sender: return false case *ForeignType: // Named Go map and slice types are descriptors like their unnamed @@ -2132,6 +2147,35 @@ func mutableParamNeedsGoPointer(t Type) bool { } } +func expressionUsesForeignDescriptor(expr Expression) bool { + switch value := expr.(type) { + case Variable: + return value.sym.foreignDescriptor + case *Variable: + return value.sym.foreignDescriptor + case *MutableRefExpr: + return expressionUsesForeignDescriptor(value.Operand) + default: + return false + } +} + +func isDescriptorBackedMutableType(t Type) bool { + base, _ := mutableRefBase(t) + switch typ := base.(type) { + case *List, *Map, *Chan, *Receiver, *Sender: + return true + case *ForeignType: + return typ.Pointer || typ.Interface || typ.MapKey != nil || typ.Elem != nil + default: + return false + } +} + +func mutableForeignParamChangesGoABI(t Type) bool { + return !isDescriptorBackedMutableType(t) +} + func (c *Checker) foreignInterfaceArgUpcast(expected Type, actual Expression) (Expression, bool) { iface, ok := expected.(*ForeignType) if !ok || !iface.Interface || actual == nil { @@ -2768,6 +2812,8 @@ func (c *Checker) checkStmt(stmt *parse.Statement) *Statement { __type: __type, } bound := c.scope.add(v.Name, v.__type, v.Mutable) + _, bindingIsReference := mutableRefBase(v.__type) + bound.foreignDescriptor = bindingIsReference && expressionUsesForeignDescriptor(val) c.recordBindingWithSpan(s.NameLocation, s.GetLocation(), bound) if c.spans != nil && c.scope.parent == nil { // Module-level values are importable; give them a canonical @@ -2794,6 +2840,15 @@ func (c *Checker) checkStmt(stmt *parse.Statement) *Statement { return nil } + if target.foreignDescriptor { + c.addDiagnostic(unreachableReferentAssignmentDiagnostic{ + Name: target.Name, + Span: c.sourceSpan(s.Target.GetLocation()), + DeclarationSpan: sourceSpanIfPresent(target.declaredAt), + }.build()) + return nil + } + // Assignment through a mutable-reference binding writes the // referent; it does not rebind, so the binding's own // (im)mutability does not gate it (ADR 0045). @@ -5437,10 +5492,10 @@ func (c *Checker) createPrimitiveMethodNode(subject Expression, methodName strin // Check for collection types if _, isList := subject.Type().(*List); isList { - return c.createListMethod(subject, methodName, args, fnDef) + return c.createListMethod(subject, methodName, args, fnDef, loc) } if _, isFixedArray := subject.Type().(*FixedArray); isFixedArray { - return c.createListMethod(subject, methodName, args, fnDef) + return c.createListMethod(subject, methodName, args, fnDef, loc) } if _, isMap := subject.Type().(*Map); isMap { return c.createMapMethod(subject, methodName, args, fnDef) @@ -5449,11 +5504,11 @@ func (c *Checker) createPrimitiveMethodNode(subject Expression, methodName strin return c.createMapMethod(subject, methodName, args, fnDef) } if foreign, isForeign := subject.Type().(*ForeignType); isForeign && foreign.Elem != nil { - return c.createListMethod(subject, methodName, args, fnDef) + return c.createListMethod(subject, methodName, args, fnDef, loc) } if foreign, isForeign := subject.Type().(*ForeignType); isForeign { if _, ok := foreign.Underlying.(*FixedArray); ok { - return c.createListMethod(subject, methodName, args, fnDef) + return c.createListMethod(subject, methodName, args, fnDef, loc) } } if _, isMaybe := subject.Type().(*Maybe); isMaybe { @@ -5724,7 +5779,7 @@ func (c *Checker) createBoolMethod(subject Expression, methodName string) Expres } } -func (c *Checker) createListMethod(subject Expression, methodName string, args []Expression, fnDef *FunctionDef) Expression { +func (c *Checker) createListMethod(subject Expression, methodName string, args []Expression, fnDef *FunctionDef, loc parse.Location) Expression { var elemType Type if listType, ok := subject.Type().(*List); ok { elemType = listType.of @@ -5760,6 +5815,23 @@ func (c *Checker) createListMethod(subject Expression, methodName string, args [ default: panic(fmt.Sprintf("Unknown List method: %s", methodName)) } + if kind == ListPush || kind == ListPrepend { + var sym Symbol + switch variable := subject.(type) { + case Variable: + sym = variable.sym + case *Variable: + sym = variable.sym + } + if sym.foreignDescriptor { + c.addDiagnostic(foreignDescriptorRebindingDiagnostic{ + Name: sym.Name, + Operation: methodName, + Span: c.sourceSpan(loc), + DeclarationSpan: sourceSpanIfPresent(sym.declaredAt), + }.build()) + } + } return &ListMethod{ Subject: subject, Kind: kind, @@ -9907,6 +9979,8 @@ func (c *Checker) checkFunctionWithSignature(def *parse.FunctionDeclaration, ini } } + fn.ForeignABI = c.foreignABIFunctions[def] + if def.IsTest { if init != nil { c.addDiagnostic(invalidTestFunctionDiagnostic{ @@ -9956,7 +10030,9 @@ func (c *Checker) checkFunctionWithSignature(def *parse.FunctionDeclaration, ini body := c.checkBlockWithExpected(def.Body, func() { c.scope.expectReturn(returnType) for _, param := range params { - c.recordBinding(param.Loc, c.scope.add(param.Name, param.Type, param.Mutable)) + sym := c.scope.add(param.Name, param.Type, param.Mutable) + sym.foreignDescriptor = fn.ForeignABI && param.Mutable && isDescriptorBackedMutableType(param.Type) + c.recordBinding(param.Loc, sym) } }, returnType, true) c.popFunctionGenericContext() @@ -10030,6 +10106,7 @@ func substituteType(t Type, typeMap map[string]Type) Type { Parameters: substitutedParams, ReturnType: substituteType(typ.ReturnType, typeMap), ForeignResultShape: typ.ForeignResultShape, + ForeignABI: typ.ForeignABI, InferReturnTypeFromBody: typ.InferReturnTypeFromBody, Body: typ.Body, Mutates: typ.Mutates, @@ -10833,6 +10910,7 @@ func (c *Checker) checkAndProcessArguments(fnDef *FunctionDef, resolvedExprs []p Parameters: make([]Parameter, len(fnDefCopy.Parameters)), ReturnType: substituteType(fnDefCopy.ReturnType, bindings), ForeignResultShape: fnDefCopy.ForeignResultShape, + ForeignABI: fnDefCopy.ForeignABI, InferReturnTypeFromBody: fnDefCopy.InferReturnTypeFromBody, Body: fnDefCopy.Body, Mutates: fnDefCopy.Mutates, diff --git a/compiler/checker/diagnostics.go b/compiler/checker/diagnostics.go index 07d4cef0..e6358934 100644 --- a/compiler/checker/diagnostics.go +++ b/compiler/checker/diagnostics.go @@ -58,6 +58,7 @@ const ( DiagnosticCodeUnsupportedMutableReference DiagnosticCode = "unsupported_mutable_reference" DiagnosticCodeInvalidForeignPointerBinding DiagnosticCode = "invalid_foreign_pointer_binding" DiagnosticCodeUnreachableReferentAssignment DiagnosticCode = "unreachable_referent_assignment" + DiagnosticCodeForeignDescriptorRebinding DiagnosticCode = "foreign_descriptor_rebinding" DiagnosticCodeReferenceRebinding DiagnosticCode = "reference_rebinding" DiagnosticCodeImmutablePropertyAssignment DiagnosticCode = "immutable_property_assignment" DiagnosticCodeImmutableReceiver DiagnosticCode = "immutable_receiver" @@ -465,6 +466,26 @@ func (d unreachableReferentAssignmentDiagnostic) build() Diagnostic { ) } +type foreignDescriptorRebindingDiagnostic struct { + Name string + Operation string + Span SourceSpan + DeclarationSpan *SourceSpan +} + +func (d foreignDescriptorRebindingDiagnostic) build() Diagnostic { + legacy := fmt.Sprintf("Cannot call '%s' on foreign descriptor reference '%s': the Go ABI cannot propagate list growth", d.Operation, d.Name) + return mutationDiagnostic( + DiagnosticCodeForeignDescriptorRebinding, + legacy, + "Foreign descriptor cannot be replaced", + "This Go ABI parameter shares list elements, but list growth cannot update the caller's slice descriptor.", + DiagnosticLabel{Span: d.Span, Message: fmt.Sprintf("`.%s()` would only update a local descriptor", d.Operation)}, + d.DeclarationSpan, + "foreign descriptor reference declared here", + ) +} + type referenceRebindingDiagnostic struct { Span SourceSpan DeclarationSpan *SourceSpan diff --git a/compiler/checker/diagnostics_test.go b/compiler/checker/diagnostics_test.go index 0159fcab..41a2da8a 100644 --- a/compiler/checker/diagnostics_test.go +++ b/compiler/checker/diagnostics_test.go @@ -1198,7 +1198,7 @@ func TestReferenceRebindingHasStructuredLabels(t *testing.T) { } func TestUnreachableReferentAssignmentHasStructuredLabels(t *testing.T) { - source := "mut items = [1, 2]\nlet ref = mut items\nref = [9, 9]\n" + source := "mut items = [1: 2]\nlet ref = mut items\nref = [9: 9]\n" result := parse.Parse([]byte(source), "main.ard") if len(result.Errors) > 0 { t.Fatalf("parse errors: %v", result.Errors) diff --git a/compiler/checker/foreign_abi_internal_test.go b/compiler/checker/foreign_abi_internal_test.go new file mode 100644 index 00000000..4af55d0a --- /dev/null +++ b/compiler/checker/foreign_abi_internal_test.go @@ -0,0 +1,28 @@ +package checker + +import "testing" + +func TestFunctionCopiesPreserveForeignABI(t *testing.T) { + typeVar := &TypeVar{name: "$T"} + original := &FunctionDef{ + Name: "write", + Parameters: []Parameter{{Name: "values", Type: &List{of: typeVar}, Mutable: true}}, + ReturnType: Void, + ForeignABI: true, + } + + substituted, ok := substituteType(original, map[string]Type{"$T": Int}).(*FunctionDef) + if !ok || !substituted.ForeignABI { + t.Fatalf("substituteType lost ForeignABI: %#v", substituted) + } + + replaced, ok := replaceGeneric(original, "$T", Int).(*FunctionDef) + if !ok || !replaced.ForeignABI { + t.Fatalf("replaceGeneric lost ForeignABI: %#v", replaced) + } + + copied := copyFunctionWithTypeVarMap(original, map[string]*TypeVar{"$T": {name: "$T"}}) + if copied == nil || !copied.ForeignABI { + t.Fatalf("copyFunctionWithTypeVarMap lost ForeignABI: %#v", copied) + } +} diff --git a/compiler/checker/go_import_test.go b/compiler/checker/go_import_test.go index c6f57af9..e21c2581 100644 --- a/compiler/checker/go_import_test.go +++ b/compiler/checker/go_import_test.go @@ -81,6 +81,81 @@ impl io::Writer for Sink { fn write(bytes: mut [Byte]) Int!Str { Result::ok(bytes.size()) } +}`, + }, + { + name: "Go interface descriptor parameters reject list growth", + input: `use go:io + +struct Sink {} + +impl io::Writer for Sink { + fn write(bytes: mut [Byte]) Int!Str { + bytes.push(bytes.at(0).expect("byte")) + Result::ok(bytes.size()) + } +}`, + diagnostics: []checker.Diagnostic{{Kind: checker.Error, Message: "Cannot call 'push' on foreign descriptor reference 'bytes': the Go ABI cannot propagate list growth"}}, + }, + { + name: "Go interface descriptor parameters reject whole-list assignment", + input: `use go:io + +struct Sink {} + +impl io::Writer for Sink { + fn write(bytes: mut [Byte]) Int!Str { + bytes = [] + Result::ok(bytes.size()) + } +}`, + diagnostics: []checker.Diagnostic{{Kind: checker.Error, Message: "Cannot assign a new value through 'bytes': element writes share storage, but the referent binding is not reachable. Assign to the original binding instead"}}, + }, + { + name: "Go interface descriptor aliases reject list growth", + input: `use go:io + +struct Sink {} + +impl io::Writer for Sink { + fn write(bytes: mut [Byte]) Int!Str { + let alias = mut bytes + alias.prepend(bytes.at(0).expect("byte")) + Result::ok(bytes.size()) + } +}`, + diagnostics: []checker.Diagnostic{{Kind: checker.Error, Message: "Cannot call 'prepend' on foreign descriptor reference 'alias': the Go ABI cannot propagate list growth"}}, + }, + { + name: "Go interface descriptor captures reject list growth", + input: `use go:io + +struct Sink {} + +impl io::Writer for Sink { + fn write(bytes: mut [Byte]) Int!Str { + let grow = fn() { + bytes.push(bytes.at(0).expect("byte")) + () + } + grow() + Result::ok(bytes.size()) + } +}`, + diagnostics: []checker.Diagnostic{{Kind: checker.Error, Message: "Cannot call 'push' on foreign descriptor reference 'bytes': the Go ABI cannot propagate list growth"}}, + }, + { + name: "Go interface descriptor value copies may grow independently", + input: `use go:io + +struct Sink {} + +impl io::Writer for Sink { + fn write(bytes: mut [Byte]) Int!Str { + mut copy = bytes + copy.push(bytes.at(0).expect("byte")) + Result::ok(copy.size()) + } }`, }, { diff --git a/compiler/checker/mut_ref_expr_test.go b/compiler/checker/mut_ref_expr_test.go index d53e9598..7775194f 100644 --- a/compiler/checker/mut_ref_expr_test.go +++ b/compiler/checker/mut_ref_expr_test.go @@ -120,11 +120,10 @@ r = mut other`, diagnostics: []checker.Diagnostic{{Kind: checker.Error, Message: "References cannot be rebound; assign the value directly"}}, }, { - name: "whole-value writes through a descriptor reference are rejected", + name: "whole-list writes through a mutable reference are allowed", input: `mut items = [1, 2] let r = mut items r = [9, 9]`, - diagnostics: []checker.Diagnostic{{Kind: checker.Error, Message: "Cannot assign a new value through 'r': element writes share storage, but the referent binding is not reachable. Assign to the original binding instead"}}, }, { name: "immutable place diagnostic stays readable for complex subjects", diff --git a/compiler/checker/nodes.go b/compiler/checker/nodes.go index 20e1a70b..32d4fd7a 100644 --- a/compiler/checker/nodes.go +++ b/compiler/checker/nodes.go @@ -1244,10 +1244,14 @@ type FunctionDef struct { DefaultVoidGeneric string // DeferCallCompleteness lets constructor adapters collect context from an // enclosing match, catch, or binding before the expression-boundary check. - DeferCallCompleteness bool - Parameters []Parameter - ReturnType Type - ForeignResultShape ForeignResultShape + DeferCallCompleteness bool + Parameters []Parameter + ReturnType Type + ForeignResultShape ForeignResultShape + // ForeignABI marks an Ard method whose generated Go signature must exactly + // match a foreign interface method. Mutable descriptor parameters retain + // their Go value shape at this boundary. + ForeignABI bool InferReturnTypeFromBody bool Mutates bool IsTest bool diff --git a/compiler/checker/scope.go b/compiler/checker/scope.go index 6fd9c7e2..6329c20f 100644 --- a/compiler/checker/scope.go +++ b/compiler/checker/scope.go @@ -48,10 +48,11 @@ type genericBindingOrigin struct { } type Symbol struct { - Name string - Type Type - declaredAt SourceSpan - mutable bool + Name string + Type Type + declaredAt SourceSpan + mutable bool + foreignDescriptor bool } func (s Symbol) IsZero() bool { @@ -819,6 +820,7 @@ func replaceGeneric(t Type, genericName string, concreteType Type) Type { Parameters: newParams, ReturnType: newReturnType, ForeignResultShape: t.ForeignResultShape, + ForeignABI: t.ForeignABI, InferReturnTypeFromBody: t.InferReturnTypeFromBody, Mutates: t.Mutates, Body: t.Body, @@ -1131,6 +1133,7 @@ func copyFunctionWithTypeVarMap(fnDef *FunctionDef, typeVarMap map[string]*TypeV Parameters: newParams, ReturnType: copyTypeWithTypeVarMap(fnDef.ReturnType, typeVarMap), ForeignResultShape: fnDef.ForeignResultShape, + ForeignABI: fnDef.ForeignABI, InferReturnTypeFromBody: fnDef.InferReturnTypeFromBody, Body: fnDef.Body, Mutates: fnDef.Mutates, diff --git a/compiler/go/backend_test.go b/compiler/go/backend_test.go index 6cbc206d..0d20c4b0 100644 --- a/compiler/go/backend_test.go +++ b/compiler/go/backend_test.go @@ -3205,7 +3205,7 @@ func TestLowerProgramUsesPointersForMutableStructParams(t *testing.T) { t.Fatal("generated AST missing pointer call lowering") } } -func TestLowerProgramUsesDescriptorsForMutableListParams(t *testing.T) { +func TestLowerProgramUsesPointersForNativeMutableListParams(t *testing.T) { program := lowerSource(t, ` fn replace_first(values: mut [Int]) Void { values.set(0, 1) @@ -3222,21 +3222,45 @@ func TestLowerProgramUsesDescriptorsForMutableListParams(t *testing.T) { if !ok || fn.Type.Params == nil || len(fn.Type.Params.List) == 0 { t.Fatalf("generated AST missing replace_first function") } - if _, ok := fn.Type.Params.List[0].Type.(*ast.StarExpr); ok { - t.Fatalf("mutable list parameter should lower as descriptor, got pointer: %#v", fn.Type.Params.List[0].Type) + paramType, ok := fn.Type.Params.List[0].Type.(*ast.StarExpr) + if !ok { + t.Fatalf("native mutable list parameter should lower as pointer: %#v", fn.Type.Params.List[0].Type) } - if _, ok := fn.Type.Params.List[0].Type.(*ast.ArrayType); !ok { - t.Fatalf("mutable list parameter should lower to slice: %#v", fn.Type.Params.List[0].Type) + if _, ok := paramType.X.(*ast.ArrayType); !ok { + t.Fatalf("native mutable list parameter should point to slice: %#v", fn.Type.Params.List[0].Type) } - if astFilesContain(files, func(node ast.Node) bool { + if !astFilesContain(files, func(node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok || astCallName(call) != "ReplaceFirst" || len(call.Args) == 0 { return false } - _, isAddr := call.Args[0].(*ast.UnaryExpr) - return isAddr + addr, isAddr := call.Args[0].(*ast.UnaryExpr) + return isAddr && addr.Op == token.AND }) { - t.Fatal("mutable list call should not pass address") + t.Fatal("native mutable list call should pass the descriptor address") + } +} + +func TestLowerProgramKeepsForeignMutableListParamsAsDescriptors(t *testing.T) { + program := lowerSource(t, ` + use go:io + + struct Sink {} + + impl io::Writer for Sink { + fn write(bytes: mut [Byte]) Int!Str { + Result::ok(bytes.size()) + } + } + `) + + files := lowerProgramAST(t, program, Options{PackageName: "main"}) + fn, ok := astFilesFunc(files, "Write") + if !ok || fn.Type.Params == nil || len(fn.Type.Params.List) == 0 { + t.Fatalf("generated AST missing Write method") + } + if _, ok := fn.Type.Params.List[0].Type.(*ast.ArrayType); !ok { + t.Fatalf("foreign mutable list parameter must keep []byte ABI: %#v", fn.Type.Params.List[0].Type) } } diff --git a/compiler/go/lower.go b/compiler/go/lower.go index fc65288e..58ed8e67 100644 --- a/compiler/go/lower.go +++ b/compiler/go/lower.go @@ -1076,7 +1076,7 @@ func (l *lowerer) lowerFunction(fn air.Function) (ast.Decl, error) { l.declaredLocals[capture.Local] = true } for i, param := range fn.Signature.Params { - paramType, err := l.goParamType(param) + paramType, err := l.goFunctionParamType(fn, param) if err != nil { return nil, err } @@ -1260,7 +1260,7 @@ func (l *lowerer) lowerGoMethodWrapper(fn air.Function) (*ast.FuncDecl, bool, er params := make([]*ast.Field, 0, len(fn.Signature.Params)-1) callArgs := []ast.Expr{ast.NewIdent(l.localName(fn, 0))} for i, param := range fn.Signature.Params[1:] { - paramType, err := l.goParamType(param) + paramType, err := l.goFunctionParamType(fn, param) if err != nil { return nil, false, err } @@ -1668,7 +1668,7 @@ func (l *lowerer) lowerRawCall(fn air.Function, expr air.Expr) (loweredExpr, err return loweredExpr{}, fmt.Errorf("not a valid call") } target := l.program.Functions[expr.Function] - args, stmts, writeback, err := l.lowerCallArgs(fn, expr.Args, concreteCallParams(expr, target)) + args, stmts, writeback, err := l.lowerCallArgs(fn, expr.Args, concreteCallParams(expr, target), target.ForeignABI) if err != nil { return loweredExpr{}, err } @@ -2814,7 +2814,7 @@ func (l *lowerer) lowerExpr(fn air.Function, expr air.Expr) (loweredExpr, error) return loweredExpr{}, fmt.Errorf("invalid function id %d", expr.Function) } target := l.program.Functions[expr.Function] - args, stmts, writeback, err := l.lowerCallArgs(fn, expr.Args, concreteCallParams(expr, target)) + args, stmts, writeback, err := l.lowerCallArgs(fn, expr.Args, concreteCallParams(expr, target), target.ForeignABI) if err != nil { return loweredExpr{}, err } @@ -3310,6 +3310,16 @@ func (l *lowerer) lowerForeignInterfaceUpcast(fn air.Function, expr air.Expr) (l return target, nil } +func (l *lowerer) foreignABIValueArg(arg air.Expr, value ast.Expr) ast.Expr { + if arg.Kind != air.ExprMutRef || !validTypeID(l.program, arg.Type) { + return value + } + if l.program.Types[arg.Type-1].Kind == air.TypeList { + return &ast.StarExpr{X: value} + } + return value +} + func (l *lowerer) lowerForeignCall(fn air.Function, expr air.Expr) (loweredExpr, error) { if expr.ForeignTarget != "go" { return loweredExpr{}, fmt.Errorf("unsupported foreign call target %q", expr.ForeignTarget) @@ -3326,7 +3336,7 @@ func (l *lowerer) lowerForeignCall(fn air.Function, expr air.Expr) (loweredExpr, return loweredExpr{}, err } stmts = append(stmts, arg.stmts...) - args = append(args, arg.expr) + args = append(args, l.foreignABIValueArg(expr.Args[i], arg.expr)) } importPath := expr.ForeignNamespace functionName := expr.ForeignSymbol @@ -3554,7 +3564,7 @@ func (l *lowerer) lowerForeignMethodCall(fn air.Function, expr air.Expr) (lowere return loweredExpr{}, err } stmts = append(stmts, arg.stmts...) - args = append(args, arg.expr) + args = append(args, l.foreignABIValueArg(expr.Args[i], arg.expr)) } call := &ast.CallExpr{Fun: &ast.SelectorExpr{X: target.expr, Sel: ast.NewIdent(expr.ForeignSymbol)}, Args: args} if validTypeID(l.program, expr.Type) { @@ -3924,7 +3934,7 @@ func (l *lowerer) mutableParamUsesPointer(typeID air.TypeID) bool { } info := l.program.Types[typeID-1] switch info.Kind { - case air.TypeList, air.TypeMap, air.TypeChannel, air.TypeReceiver, air.TypeSender: + case air.TypeMap, air.TypeChannel, air.TypeReceiver, air.TypeSender: return false case air.TypeForeignType: // Named Go map and slice types are descriptors like their unnamed @@ -3941,6 +3951,25 @@ func (l *lowerer) mutableParamUsesPointer(typeID air.TypeID) bool { } } +func (l *lowerer) foreignABIKeepsMutableDescriptor(typeID air.TypeID) bool { + if !validTypeID(l.program, typeID) { + return false + } + info := l.program.Types[typeID-1] + switch info.Kind { + case air.TypeList, air.TypeMap, air.TypeChannel, air.TypeReceiver, air.TypeSender: + return true + case air.TypeForeignType: + return info.ForeignPointer || info.ForeignInterface || info.Elem != air.NoType || info.Key != air.NoType && info.Value != air.NoType + default: + return false + } +} + +func (l *lowerer) functionMutableParamUsesPointer(fn air.Function, typeID air.TypeID) bool { + return l.mutableParamUsesPointer(typeID) && !(fn.ForeignABI && l.foreignABIKeepsMutableDescriptor(typeID)) +} + func (l *lowerer) mutableParamType(typeID air.TypeID) (ast.Expr, error) { typ, err := l.goType(typeID) if err != nil { @@ -3965,6 +3994,13 @@ func (l *lowerer) goParamType(param air.Param) (ast.Expr, error) { return l.goType(param.Type) } +func (l *lowerer) goFunctionParamType(fn air.Function, param air.Param) (ast.Expr, error) { + if param.Mutable && !l.functionMutableParamUsesPointer(fn, param.Type) { + return l.goType(param.Type) + } + return l.goParamType(param) +} + func (l *lowerer) modulePathForType(typeID air.TypeID) string { if validTypeID(l.program, typeID) && l.program.Types[typeID-1].ModulePath != "" { return l.program.Types[typeID-1].ModulePath @@ -4524,7 +4560,7 @@ func (l *lowerer) resolvedExprType(fn air.Function, expr air.Expr) air.TypeID { return expr.Type } -func (l *lowerer) lowerCallArgs(fn air.Function, rawArgs []air.Expr, params []air.Param) ([]ast.Expr, []ast.Stmt, []ast.Stmt, error) { +func (l *lowerer) lowerCallArgs(fn air.Function, rawArgs []air.Expr, params []air.Param, foreignABI bool) ([]ast.Expr, []ast.Stmt, []ast.Stmt, error) { args := make([]ast.Expr, 0, len(rawArgs)) stmts := []ast.Stmt{} writeback := []ast.Stmt{} @@ -4548,7 +4584,7 @@ func (l *lowerer) lowerCallArgs(fn air.Function, rawArgs []air.Expr, params []ai if i < len(params) { var setup []ast.Stmt var post []ast.Stmt - argExpr, setup, post, err = l.adaptCallArgWithStmts(fn, arg, argExpr, params[i]) + argExpr, setup, post, err = l.adaptCallArgWithStmts(fn, arg, argExpr, params[i], foreignABI) if err != nil { return nil, nil, nil, err } @@ -4643,8 +4679,9 @@ func (l *lowerer) adaptCallArg(fn air.Function, arg air.Expr, argExpr ast.Expr, return l.mutableReferenceArg(fn, arg, argExpr) } -func (l *lowerer) adaptCallArgWithStmts(fn air.Function, arg air.Expr, argExpr ast.Expr, param air.Param) (ast.Expr, []ast.Stmt, []ast.Stmt, error) { - if !param.Mutable || !l.mutableParamUsesPointer(param.Type) { +func (l *lowerer) adaptCallArgWithStmts(fn air.Function, arg air.Expr, argExpr ast.Expr, param air.Param, foreignABI bool) (ast.Expr, []ast.Stmt, []ast.Stmt, error) { + usesPointer := l.mutableParamUsesPointer(param.Type) && !(foreignABI && l.foreignABIKeepsMutableDescriptor(param.Type)) + if !param.Mutable || !usesPointer { return argExpr, nil, nil, nil } if adapted, setup, writeback, ok, err := l.mutableTraitObjectArg(fn, arg, argExpr, param); ok || err != nil { @@ -5132,7 +5169,7 @@ func (l *lowerer) localIsPointerParam(fn air.Function, local air.LocalID) bool { idx := int(local) if idx >= 0 && idx < len(fn.Signature.Params) { param := fn.Signature.Params[idx] - return param.Mutable && l.mutableParamUsesPointer(param.Type) + return param.Mutable && l.functionMutableParamUsesPointer(fn, param.Type) } for _, capture := range fn.Captures { if capture.Local != local || idx < 0 || idx >= len(fn.Locals) { @@ -6581,7 +6618,7 @@ func (l *lowerer) lowerMakeClosure(fn air.Function, expr air.Expr) (loweredExpr, } var setup []ast.Stmt var post []ast.Stmt - argExpr, setup, post, err = l.adaptCallArgWithStmts(fn, air.Expr{Kind: air.ExprLoadLocal, Type: capture.Type, Local: local}, argExpr, captureParam) + argExpr, setup, post, err = l.adaptCallArgWithStmts(fn, air.Expr{Kind: air.ExprLoadLocal, Type: capture.Type, Local: local}, argExpr, captureParam, false) if err != nil { return loweredExpr{}, err } @@ -6740,7 +6777,7 @@ func (l *lowerer) lowerCallClosure(fn air.Function, expr air.Expr) (loweredExpr, } } } - args, stmts, writeback, err := l.lowerCallArgs(fn, expr.Args, params) + args, stmts, writeback, err := l.lowerCallArgs(fn, expr.Args, params, false) if err != nil { return loweredExpr{}, err } @@ -7498,7 +7535,7 @@ func (l *lowerer) lowerNativeTraitInterfaceCall(fn air.Function, target loweredE if !ok { return loweredExpr{}, fmt.Errorf("trait method %s cannot be lowered as a Go method", method.Name) } - args, argStmts, writeback, err := l.lowerCallArgs(fn, expr.Args, method.Signature.Params) + args, argStmts, writeback, err := l.lowerCallArgs(fn, expr.Args, method.Signature.Params, false) if err != nil { return loweredExpr{}, err } @@ -7532,7 +7569,7 @@ func (l *lowerer) lowerMutableTraitRefCall(fn air.Function, target loweredExpr, return loweredExpr{}, fmt.Errorf("invalid trait method %d for %s", expr.Method, trait.Name) } method := trait.Methods[expr.Method] - args, argStmts, writeback, err := l.lowerCallArgs(fn, expr.Args, method.Signature.Params) + args, argStmts, writeback, err := l.lowerCallArgs(fn, expr.Args, method.Signature.Params, false) if err != nil { return loweredExpr{}, err } @@ -7599,7 +7636,7 @@ func (l *lowerer) lowerTraitObjectCall(fn air.Function, target loweredExpr, expr var setup []ast.Stmt var post []ast.Stmt var adaptErr error - argExpr, setup, post, adaptErr = l.adaptCallArgWithStmts(fn, expr.Args[i], argExpr, method.Signature.Params[i]) + argExpr, setup, post, adaptErr = l.adaptCallArgWithStmts(fn, expr.Args[i], argExpr, method.Signature.Params[i], false) if adaptErr != nil { return loweredExpr{}, adaptErr } @@ -7640,7 +7677,7 @@ func (l *lowerer) lowerTraitObjectCall(fn air.Function, target loweredExpr, expr var setup []ast.Stmt var post []ast.Stmt var adaptErr error - argExpr, setup, post, adaptErr = l.adaptCallArgWithStmts(fn, expr.Args[i], argExpr, methodFn.Signature.Params[paramIndex]) + argExpr, setup, post, adaptErr = l.adaptCallArgWithStmts(fn, expr.Args[i], argExpr, methodFn.Signature.Params[paramIndex], false) if adaptErr != nil { return loweredExpr{}, adaptErr } diff --git a/compiler/go/parity_test.go b/compiler/go/parity_test.go index bb90e12c..7a9417bb 100644 --- a/compiler/go/parity_test.go +++ b/compiler/go/parity_test.go @@ -2199,7 +2199,7 @@ func TestGoTargetParityMutableReferenceParameterUpdatesCaller(t *testing.T) { } }) - t.Run("list descriptor header rebinding is local", func(t *testing.T) { + t.Run("native mutable list parameter preserves growth", func(t *testing.T) { program := lowerParitySource(t, ` fn append_one(values: mut [Int]) { values.push(1) @@ -2211,8 +2211,61 @@ func TestGoTargetParityMutableReferenceParameterUpdatesCaller(t *testing.T) { values.size() } `) - if got := runGoTargetParityJSON(t, program); got != "0" { - t.Fatalf("got %s, want 0", got) + if got := runGoTargetParityJSON(t, program); got != "1" { + t.Fatalf("got %s, want 1", got) + } + }) + + t.Run("native mutable list references preserve descriptor writes", func(t *testing.T) { + program := lowerParitySource(t, ` + type Grow = fn(mut [Int]) + + fn push_one(values: mut [Int]) { + values.push(1) + } + + fn prepend_two(values: mut [Int]) { + values.prepend(2) + } + + fn forward(values: mut [Int]) { + push_one(values) + } + + fn push_generic(values: mut [$T], value: $T) { + values.push(value) + } + + fn replace(values: mut [Int]) { + values = [9, 8] + } + + fn later(values: mut [Int]) fn() { + fn() { + values.push(7) + () + } + } + + fn main() Int { + mut values = [0] + push_one(values) + prepend_two(values) + forward(values) + push_generic(values, 3) + let alias = mut values + alias.push(4) + let grow: Grow = push_one + grow(values) + let grow_later = later(values) + grow_later() + let before_replace = values.size() + replace(values) + before_replace * 100 + values.at(0).expect("first") * 10 + values.size() + } + `) + if got := runGoTargetParityJSON(t, program); got != "892" { + t.Fatalf("got %s, want 892", got) } }) diff --git a/docs/adrs/0040-decouple-mutability-from-go-pointer-lowering.md b/docs/adrs/0040-decouple-mutability-from-go-pointer-lowering.md index 52de188f..55c796a1 100644 --- a/docs/adrs/0040-decouple-mutability-from-go-pointer-lowering.md +++ b/docs/adrs/0040-decouple-mutability-from-go-pointer-lowering.md @@ -2,7 +2,11 @@ ## Status -Proposed +Accepted + +Amended: native Ard mutable list references include the list descriptor and lower +pointer-shaped on the Go target. Descriptor-only mutable access is retained at +foreign Go ABI boundaries whose signatures require a slice value. ## Context @@ -38,21 +42,23 @@ func (s *Sink) Write(bytes *[]byte) (int, error) because `*[]byte` does not satisfy `io.Writer`. -The same distinction applies outside interface implementations. An ordinary Ard function: +The distinction differs for ordinary Ard functions. A native mutable list reference must reach the caller's entire list value, including the slice descriptor, so operations that grow or replace the list remain caller-visible: ```ard -fn fill(mut xs: [Int]) { - // mutate elements +fn fill(xs: mut [Int]) { + xs.push(1) } ``` -should lower to: +On the Go target this lowers approximately as: ```go -func Fill(xs []int) +func Fill(xs *[]int) { + *xs = append(*xs, 1) +} ``` -not `func Fill(xs *[]int)`, because the Go slice descriptor already carries mutable access to the backing array. Mutability is an Ard access capability; it is not synonymous with a Go pointer. +Mutability remains an Ard access capability rather than a Go pointer spelling. The pointer is a backend representation for native list-reference semantics; it is omitted at foreign boundaries where the Go ABI fixes the parameter as a slice value. ## Decision @@ -70,10 +76,11 @@ The Go backend should distinguish at least these mutable access shapes: - `mut Person` lowers as `*Person` when passed as a mutable parameter. 2. **Descriptor mutable access** - - Used when the Go value is already a reference-like descriptor. - - Examples: slices, maps, channels, Go pointers, and Go interfaces. - - `mut [T]` lowers as `[]T`. - - `mut [K: V]` lowers as `map[K]V`. + - Used when all Ard mutations are naturally visible through a copied Go descriptor, or when a foreign ABI fixes the descriptor shape. + - Examples: maps, channels, Go pointers, Go interfaces, and slices in foreign ABI positions. + - Native `mut [T]` is excluded because list growth and replacement must update the owner; it lowers as `*[]T`. + - Foreign slice positions remain `[]T` and provide element-level mutable access only. + - `mut [K: V]` lowers as `map[K]V` because map entry mutation does not replace the map descriptor. - `mut foreign::PointerType` lowers as its Go pointer type, not as a pointer to that pointer. 3. **Foreign ABI mutable access** @@ -83,19 +90,19 @@ The Go backend should distinguish at least these mutable access shapes: ### Lists and slices -Ard lists lower to Go slices. A mutable list parameter lowers to a Go slice parameter: +Ard lists lower to Go slices, but native mutable list references lower as pointers to the slice descriptor: ```ard -fn fill(mut xs: [Int]) +fn fill(xs: mut [Int]) ``` ```go -func Fill(xs []int) +func Fill(xs *[]int) ``` -This gives mutable access to the elements. Operations that rebind the slice header, such as `push` when it appends and assigns the resulting slice descriptor, update only the callee's local descriptor in this representation. Caller-visible growth requires returning the new list, assigning through an owning field/local, or a future explicit operation that writes back the descriptor. +This makes element mutation, `push`, `prepend`, and whole-list assignment visible to the caller. Explicit aliases, nested forwarding, closures, function values, and generic functions preserve the same reference to the descriptor. -The important rule for this ADR is that `mut [T]` does not automatically mean `*[]T`, and callers should not rely on mutable list parameters to rebind the caller's slice header. +Foreign Go ABI positions remain exact. A Go parameter or interface method requiring `[]T` continues to receive `[]T`, not `*[]T`. Such a parameter may mutate elements, but the checker rejects descriptor-rebinding operations such as `push` and `prepend` because Go cannot propagate the replacement descriptor to its caller. ### Maps and channels @@ -157,9 +164,9 @@ This avoids encoding Go pointer decisions into the core type system. - Ard's `mut` remains a language-level access concept rather than a Go pointer spelling. - Mutable slices/maps/channels can interoperate with idiomatic Go signatures. - Go interface implementations can use `mut` for descriptor-backed values without breaking interface satisfaction. -- Ordinary Ard functions become more idiomatic in generated Go for descriptor-backed values. -- The backend must distinguish element/content mutation from descriptor rebinding for lists and similar values. -- Existing lowering that maps every mutable parameter to `*T` must be revised. +- Native mutable list references satisfy Ard's caller-visible alias semantics even though their generated Go signature uses `*[]T`. +- The checker and backend distinguish native list references from foreign slice descriptors. +- Descriptor-rebinding list operations are rejected only where a foreign Go ABI cannot carry the updated descriptor. ## Related diff --git a/docs/adrs/0045-support-explicit-mutable-reference-expressions.md b/docs/adrs/0045-support-explicit-mutable-reference-expressions.md index 53193837..939a00d3 100644 --- a/docs/adrs/0045-support-explicit-mutable-reference-expressions.md +++ b/docs/adrs/0045-support-explicit-mutable-reference-expressions.md @@ -84,7 +84,7 @@ There is no dereference operator. Creating an alias is the visibility-worthy act - Reads see through references implicitly: field access, method calls, and operators on a `mut T` operate on the referent. - Writes through a `mut T` place assign the referent; they do not rebind the reference. -- A reference binding cannot be rebound: assigning another reference through it is rejected (`References cannot be rebound; assign the value directly`), and whole-value assignment through a descriptor-backed reference is rejected because the alias shares element storage, not the original binding slot. +- A reference binding cannot be rebound: assigning another reference through it is rejected (`References cannot be rebound; assign the value directly`). Native mutable list references include the list descriptor, so whole-list assignment updates the referent. Descriptor-only foreign ABI parameters still cannot replace their caller's descriptor. - A value copy is produced whenever a reference flows into a value context — a `T` parameter, a binding, or a `T` field. Reads dereference, so binding a reference-typed place copies the referent: ```ard @@ -109,7 +109,7 @@ This keeps the pairing uniform: copies are quiet, aliases are always spelled `mu - `mut ` where the place is already reference-shaped lowers to the reference itself — aliasing copies the pointer, never adding indirection. - Interface satisfaction needs no backend changes: the operand is already the pointer form when it reaches the boundary. -Not every Ard reference is a Go pointer (ADR 0040): descriptor-backed types (lists, maps, channels) already share storage by value, so `mut` of those lowers to the descriptor itself. The backend's existing per-representation decision applies; `&` is emitted only where the representation requires a pointer. +Not every Ard reference is a Go pointer (ADR 0040): maps and channels share all supported mutation through their descriptors. Native mutable list references are pointer-shaped because list growth and replacement must update the owner's descriptor. At foreign Go slice boundaries, the exact `[]T` ABI is retained and only element-level mutable access is available. ## Consequences