From a4dbb67b51c22bf33c14578ab75d79e161c95d56 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 18:10:29 -0300 Subject: [PATCH 01/12] feat(sema): add core semantic checks --- src/internal/sema/checker.go | 280 +++++++++++++++ src/internal/sema/expressions.go | 416 +++++++++++++++++++++++ src/internal/sema/module.go | 10 +- src/internal/sema/semantic_diagnostic.go | 34 ++ src/internal/sema/statements.go | 319 +++++++++++++++++ src/internal/sema/symbols.go | 98 ++++++ src/internal/sema/types.go | 124 +++++++ 7 files changed, 1278 insertions(+), 3 deletions(-) create mode 100644 src/internal/sema/checker.go create mode 100644 src/internal/sema/expressions.go create mode 100644 src/internal/sema/semantic_diagnostic.go create mode 100644 src/internal/sema/statements.go create mode 100644 src/internal/sema/symbols.go create mode 100644 src/internal/sema/types.go diff --git a/src/internal/sema/checker.go b/src/internal/sema/checker.go new file mode 100644 index 0000000..cacee52 --- /dev/null +++ b/src/internal/sema/checker.go @@ -0,0 +1,280 @@ +package sema + +import ( + "fmt" + "strings" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +type checker struct { + project *Project + diagnostics []diagnostic.Diagnostic +} + +func Check(project *Project) Result { + if project == nil { + return Result{Diagnostics: []diagnostic.Diagnostic{}} + } + + checker := &checker{ + project: project, + diagnostics: make([]diagnostic.Diagnostic, 0), + } + checker.initializeModules() + checker.indexDeclarations() + checker.checkModules() + + return Result{ + Project: project, + Diagnostics: checker.diagnostics, + } +} + +func (checker *checker) initializeModules() { + for _, module := range checker.project.Modules { + if module == nil { + continue + } + module.Symbols = newSymbolTable() + module.ExpressionTypes = make(map[ast.Expression]Type) + module.ResolvedCalls = make(map[*ast.CallExpr]*FunctionSymbol) + module.ResolvedVariables = make(map[*ast.VariableExpr]*VariableSymbol) + } +} + +func (checker *checker) indexDeclarations() { + for _, module := range checker.project.Modules { + if module == nil || module.Syntax == nil { + continue + } + for _, declaration := range module.Syntax.Declarations { + switch declaration := declaration.(type) { + case *ast.FunctionDecl: + checker.indexFunction(module, declaration) + case *ast.GlobalAssignment: + checker.indexGlobal(module, declaration) + } + } + } +} + +func (checker *checker) indexFunction(module *Module, declaration *ast.FunctionDecl) { + if declaration == nil { + return + } + + symbol := &FunctionSymbol{ + Name: declaration.Name.Name, + Declaration: declaration, + Module: module, + ReturnType: Type{Kind: TypeUnknown}, + Public: declaration.Public, + } + for _, parameter := range declaration.Parameters { + symbol.Parameters = append(symbol.Parameters, checker.resolveType(module, parameter.Type)) + } + if declaration.ReturnType != nil { + symbol.ReturnType = checker.resolveType(module, declaration.ReturnType) + } + if symbol.Name != "" { + module.Symbols.Functions[symbol.Name] = symbol + } +} + +func (checker *checker) indexGlobal(module *Module, declaration *ast.GlobalAssignment) { + if declaration == nil || declaration.Target == nil { + return + } + + target := declaration.Target + if target.Local && declaration.Public { + checker.report(module, target, diagnostic.CodeInvalidPublicLocalVariable, + "Local variables cannot be public.", + "Only global variables can be exported.") + return + } + if target.Local || target.Name.Name == "" { + return + } + + module.Symbols.Globals[target.Name.Name] = &VariableSymbol{ + Name: target.Name.Name, + Declaration: declaration, + Module: module, + Type: Type{Kind: TypeUnknown}, + Public: declaration.Public, + } +} + +func (checker *checker) resolveType(module *Module, ref *ast.TypeRef) Type { + if ref == nil { + return Type{Kind: TypeUnknown} + } + + kind, ok := builtInTypes[ref.Name.Name] + if !ok { + checker.report(module, &ref.Name, diagnostic.CodeUndefinedType, + fmt.Sprintf("Undefined type: %s", ref.Name.Name), "") + for _, argument := range ref.Arguments { + checker.resolveType(module, argument) + } + return Type{Kind: TypeUnknown} + } + + typ := Type{Kind: kind, Name: ref.Name.Name} + for _, argument := range ref.Arguments { + typ.Arguments = append(typ.Arguments, checker.resolveType(module, argument)) + } + return typ +} + +func (checker *checker) checkModules() { + for _, module := range checker.project.Modules { + if module == nil || module.Syntax == nil { + continue + } + checker.checkRequiredEvents(module) + checker.checkGlobalInitializers(module) + } + + for _, module := range checker.project.Modules { + if module == nil || module.Syntax == nil { + continue + } + for _, declaration := range module.Syntax.Declarations { + switch declaration := declaration.(type) { + case *ast.FunctionDecl: + checker.checkFunction(module, declaration) + case *ast.EventDecl: + checker.checkEvent(module, declaration) + } + } + } +} + +func (checker *checker) checkGlobalInitializers(module *Module) { + for _, declaration := range module.Syntax.Declarations { + global, ok := declaration.(*ast.GlobalAssignment) + if !ok || global == nil { + continue + } + + typ := checker.checkExpression(module, nil, global.Value) + if global.Target == nil || global.Target.Local { + continue + } + if symbol, ok := module.Symbols.Globals[global.Target.Name.Name]; ok { + symbol.Type = typ + module.ResolvedVariables[global.Target] = symbol + } + checker.checkVariableAccesses(module, nil, global.Target) + } +} + +func (checker *checker) checkFunction(module *Module, declaration *ast.FunctionDecl) { + if declaration == nil { + return + } + + currentScope := checker.runtimeScope() + for index := range declaration.Parameters { + parameter := &declaration.Parameters[index] + typ := Type{Kind: TypeUnknown} + if symbol := module.Symbols.Functions[declaration.Name.Name]; symbol != nil && index < len(symbol.Parameters) { + typ = symbol.Parameters[index] + } + currentScope.defineName(parameter.Name.Name, typ) + } + + context := flowContext{ + function: declaration, + returnType: func() Type { + if symbol := module.Symbols.Functions[declaration.Name.Name]; symbol != nil { + return symbol.ReturnType + } + return Type{Kind: TypeUnknown} + }(), + } + fallsThrough := checker.checkBlock(module, currentScope, declaration.Body, context) + if declaration.ReturnType != nil && fallsThrough { + checker.report(module, &declaration.Name, diagnostic.CodeMissingReturn, + fmt.Sprintf("Function %s must return %s in all paths.", declaration.Name.Name, context.returnType.String()), + "Add an else branch or a final return.") + } +} + +func (checker *checker) checkEvent(module *Module, declaration *ast.EventDecl) { + if declaration == nil { + return + } + + currentScope := checker.runtimeScope() + if eventName(declaration) == "join" { + currentScope.defineName("player", Type{Kind: TypeNamed, Name: "Player"}) + } + checker.checkBlock(module, currentScope, declaration.Body, flowContext{}) +} + +func (checker *checker) runtimeScope() *scope { + currentScope := newExecutionScope() + currentScope.defineName("console", Type{Kind: TypeNamed, Name: "Command"}) + return currentScope +} + +func (checker *checker) checkRequiredEvents(module *Module) { + required := make(map[string]*ast.MetadataEntry) + for index := range module.Syntax.Metadata { + entry := &module.Syntax.Metadata[index] + if entry.Key != "tags" { + continue + } + for _, tag := range strings.Split(entry.Value, ",") { + tag = strings.TrimSpace(tag) + if tag == "load" || tag == "tick" { + required[tag] = entry + } + } + } + if len(required) == 0 { + return + } + + declared := make(map[string]bool) + for _, declaration := range module.Syntax.Declarations { + event, ok := declaration.(*ast.EventDecl) + if !ok || event == nil || len(event.Name) != 1 { + continue + } + declared[event.Name[0].Name] = true + } + + if entry := required["load"]; entry != nil && !declared["load"] { + checker.report(module, entry, diagnostic.CodeMissingLoadEvent, + "Missing required event: on load", + "Add an on load block or remove the load tag.") + } + if entry := required["tick"]; entry != nil && !declared["tick"] { + checker.report(module, entry, diagnostic.CodeMissingTickEvent, + "Missing required event: on tick", + "Add an on tick block or remove the tick tag.") + } +} + +func eventName(declaration *ast.EventDecl) string { + if declaration == nil || len(declaration.Name) != 1 { + return "" + } + return declaration.Name[0].Name +} + +func (checker *checker) report( + module *Module, + node ast.Node, + code diagnostic.Code, + message string, + hint string, +) { + checker.diagnostics = append(checker.diagnostics, semanticDiagnostic(module, node, code, message, hint)) +} diff --git a/src/internal/sema/expressions.go b/src/internal/sema/expressions.go new file mode 100644 index 0000000..57a6ecb --- /dev/null +++ b/src/internal/sema/expressions.go @@ -0,0 +1,416 @@ +package sema + +import ( + "fmt" + "strings" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/token" +) + +func (checker *checker) checkExpression(module *Module, currentScope *scope, expression ast.Expression) Type { + if expression == nil { + return Type{Kind: TypeUnknown} + } + + var typ Type + switch expression := expression.(type) { + case *ast.NilLiteral: + typ = Type{Kind: TypeNil} + case *ast.BoolLiteral: + typ = Type{Kind: TypeBool} + case *ast.IntLiteral: + typ = Type{Kind: TypeInt} + case *ast.FloatLiteral: + typ = Type{Kind: TypeFloat} + case *ast.StringExpr: + typ = checker.checkString(module, currentScope, expression) + case *ast.UnaryExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkUnary(module, currentScope, expression) + case *ast.BinaryExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkBinary(module, currentScope, expression) + case *ast.GroupExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkExpression(module, currentScope, expression.Expression) + case *ast.CallExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkCall(module, currentScope, expression) + case *ast.VariableExpr: + typ = checker.checkVariable(module, currentScope, expression) + case *ast.ListExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkList(module, currentScope, expression) + case *ast.MapExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkMap(module, currentScope, expression) + case *ast.RangeExpr: + if expression == nil { + return Type{Kind: TypeUnknown} + } + typ = checker.checkRange(module, currentScope, expression) + case *ast.PatternExpr, *ast.AccessExpr: + typ = Type{Kind: TypeUnknown} + default: + typ = Type{Kind: TypeUnknown} + } + + if module != nil && module.ExpressionTypes != nil { + module.ExpressionTypes[expression] = typ + } + return typ +} + +func (checker *checker) checkString(module *Module, currentScope *scope, expression *ast.StringExpr) Type { + if expression != nil { + for _, part := range expression.Parts { + if interpolation, ok := part.(*ast.StringInterpolation); ok { + checker.checkExpression(module, currentScope, interpolation.Expression) + } + } + } + return Type{Kind: TypeString} +} + +func (checker *checker) checkUnary(module *Module, currentScope *scope, expression *ast.UnaryExpr) Type { + operand := checker.checkExpression(module, currentScope, expression.Operand) + switch expression.Operator { + case token.Not: + if !operand.IsUnknown() && operand.Kind != TypeBool { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot use not with %s.", operand.String())) + } + return Type{Kind: TypeBool} + case token.Minus: + if operand.IsUnknown() { + return operand + } + if operand.Kind != TypeInt && operand.Kind != TypeFloat { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot negate %s.", operand.String())) + return Type{Kind: TypeUnknown} + } + return operand + default: + return Type{Kind: TypeUnknown} + } +} + +func (checker *checker) checkBinary(module *Module, currentScope *scope, expression *ast.BinaryExpr) Type { + left := checker.checkExpression(module, currentScope, expression.Left) + right := checker.checkExpression(module, currentScope, expression.Right) + + switch expression.Operator { + case token.Plus, token.Minus, token.Star, token.Slash, token.Percent: + if expression.Operator == token.Plus && left.Kind == TypeString && right.Kind == TypeString { + return Type{Kind: TypeString} + } + result := numericType(left, right) + if !left.IsUnknown() && !right.IsUnknown() && result.IsUnknown() { + checker.typeMismatch(module, expression, arithmeticMismatch(expression.Operator, left, right)) + } + return result + case token.And, token.Or: + if (!left.IsUnknown() && left.Kind != TypeBool) || (!right.IsUnknown() && right.Kind != TypeBool) { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot use %s with %s and %s.", + operatorText(expression.Operator), left.String(), right.String())) + } + return Type{Kind: TypeBool} + case token.EqualEqual, token.BangEqual: + if !left.IsUnknown() && !right.IsUnknown() && + !compatible(left, right) && !compatible(right, left) { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot compare %s and %s.", left.String(), right.String())) + } + return Type{Kind: TypeBool} + case token.Greater, token.GreaterEq, token.Less, token.LessEq: + if !left.IsUnknown() && !right.IsUnknown() && numericType(left, right).IsUnknown() { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot compare %s and %s.", left.String(), right.String())) + } + return Type{Kind: TypeBool} + default: + return Type{Kind: TypeUnknown} + } +} + +func (checker *checker) checkCall(module *Module, currentScope *scope, call *ast.CallExpr) Type { + for _, argument := range call.Arguments { + checker.checkExpression(module, currentScope, argument) + } + + name := qualifiedName(call.Callee) + if !call.ExplicitParens { + if typ, ok := currentScope.lookupName(name); ok { + return typ + } + } + + function := checker.resolveFunction(module, call) + if function == nil { + if isContextualName(name) { + hint := "Declare the name before using it." + if name == "player" && currentScope != nil { + hint = `The name "player" is only available inside events that inject a player.` + } + checker.report(module, &call.Callee, diagnostic.CodeUndefinedName, + fmt.Sprintf("Undefined name: %s", name), + hint) + } else { + checker.report(module, &call.Callee, diagnostic.CodeUndefinedFunction, + fmt.Sprintf("Undefined function: %s", name), + fmt.Sprintf("Declare fun %s before using it, or import it from a module.", name)) + } + return Type{Kind: TypeUnknown} + } + + module.ResolvedCalls[call] = function + checker.checkArguments(module, call, function) + return function.ReturnType +} + +func (checker *checker) resolveFunction(module *Module, call *ast.CallExpr) *FunctionSymbol { + if module == nil || module.Symbols == nil || call == nil { + return nil + } + + parts := call.Callee.Parts + if len(parts) == 1 { + return module.Symbols.Functions[parts[0].Name] + } + if len(parts) != 2 { + return nil + } + + imported, ok := module.Import(parts[0].Name) + if !ok || imported == nil || imported.Target == nil || imported.Target.Symbols == nil { + return nil + } + function := imported.Target.Symbols.Functions[parts[1].Name] + if function == nil || !function.Public { + return nil + } + return function +} + +func (checker *checker) checkArguments(module *Module, call *ast.CallExpr, function *FunctionSymbol) { + expected := len(function.Parameters) + actual := len(call.Arguments) + if expected > 0 && actual < expected { + checker.report(module, call, diagnostic.CodeMissingArguments, + fmt.Sprintf("Missing arguments for function: %s", qualifiedName(call.Callee)), + fmt.Sprintf("Call it with parentheses: %s(%s)", + qualifiedName(call.Callee), strings.Join(parameterNames(function.Declaration), ", "))) + } + if actual > expected { + checker.report(module, call, diagnostic.CodeTooManyArguments, "Too many arguments.", "") + } + + limit := actual + if expected < limit { + limit = expected + } + for index := 0; index < limit; index++ { + actualType := module.ExpressionTypes[call.Arguments[index]] + if !compatible(function.Parameters[index], actualType) { + checker.report(module, call.Arguments[index], diagnostic.CodeInvalidArgumentType, + "Invalid argument type.", "") + } + } +} + +func parameterNames(declaration *ast.FunctionDecl) []string { + if declaration == nil { + return nil + } + names := make([]string, 0, len(declaration.Parameters)) + for _, parameter := range declaration.Parameters { + names = append(names, parameter.Name.Name) + } + return names +} + +func qualifiedName(name ast.QualifiedName) string { + parts := make([]string, 0, len(name.Parts)) + for _, part := range name.Parts { + parts = append(parts, part.Name) + } + return strings.Join(parts, ".") +} + +func isContextualName(name string) bool { + switch name { + case "player", "console", "loop.index", "loop.value", "loop.player", "loop.entity": + return true + default: + return false + } +} + +func (checker *checker) checkVariable(module *Module, currentScope *scope, variable *ast.VariableExpr) Type { + if variable == nil { + return Type{Kind: TypeUnknown} + } + checker.checkVariableAccesses(module, currentScope, variable) + + var symbol *VariableSymbol + if variable.Qualifier != nil { + imported, ok := module.Import(variable.Qualifier.Name) + if ok && imported != nil && imported.Target != nil && imported.Target.Symbols != nil { + symbol = imported.Target.Symbols.Globals[variable.Name.Name] + if symbol != nil && !symbol.Public { + symbol = nil + } + } + } else if variable.Local { + symbol, _ = currentScope.lookupLocal(variable.Name.Name) + } else if typ, ok := currentScope.lookupName(variable.Name.Name); ok { + return checker.typeAfterAccesses(typ, variable.Accesses) + } else if module != nil && module.Symbols != nil { + symbol = module.Symbols.Globals[variable.Name.Name] + } + + if symbol == nil { + checker.report(module, variable, diagnostic.CodeUndefinedVariable, + fmt.Sprintf("Undefined variable: %s", variableName(variable)), + fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) + return Type{Kind: TypeUnknown} + } + + module.ResolvedVariables[variable] = symbol + return checker.typeAfterAccesses(symbol.Type, variable.Accesses) +} + +func (checker *checker) checkVariableAccesses(module *Module, currentScope *scope, variable *ast.VariableExpr) { + if variable == nil { + return + } + for _, access := range variable.Accesses { + if index, ok := access.(*ast.IndexAccess); ok { + checker.checkExpression(module, currentScope, index.Index) + } + } +} + +func (checker *checker) typeAfterAccesses(typ Type, accesses []ast.VariableAccess) Type { + for _, access := range accesses { + switch access.(type) { + case *ast.FieldAccess: + typ = Type{Kind: TypeUnknown} + case *ast.IndexAccess: + if len(typ.Arguments) > 0 && (typ.Kind == TypeList || typ.Kind == TypeRange) { + typ = typ.Arguments[0] + } else if len(typ.Arguments) > 1 && typ.Kind == TypeMap { + typ = typ.Arguments[1] + } else { + typ = Type{Kind: TypeUnknown} + } + case *ast.EmptyIndexAccess: + // Empty brackets identify the collection itself in Puff. + } + } + return typ +} + +func variableName(variable *ast.VariableExpr) string { + if variable == nil { + return "$" + } + name := "$" + if variable.Local { + name += "_" + } + name += variable.Name.Name + if variable.Qualifier != nil { + name = variable.Qualifier.Name + "." + name + } + return name +} + +func (checker *checker) checkList(module *Module, currentScope *scope, expression *ast.ListExpr) Type { + elementType := Type{Kind: TypeUnknown} + for index, element := range expression.Elements { + current := checker.checkExpression(module, currentScope, element) + if index == 0 { + elementType = current + } else if !compatible(elementType, current) && !compatible(current, elementType) { + elementType = Type{Kind: TypeUnknown} + } + } + return Type{Kind: TypeList, Arguments: []Type{elementType}} +} + +func (checker *checker) checkMap(module *Module, currentScope *scope, expression *ast.MapExpr) Type { + keyType := Type{Kind: TypeUnknown} + valueType := Type{Kind: TypeUnknown} + for index, entry := range expression.Entries { + key := checker.checkExpression(module, currentScope, entry.Key) + value := checker.checkExpression(module, currentScope, entry.Value) + if index == 0 { + keyType = key + valueType = value + continue + } + if !compatible(keyType, key) && !compatible(key, keyType) { + keyType = Type{Kind: TypeUnknown} + } + if !compatible(valueType, value) && !compatible(value, valueType) { + valueType = Type{Kind: TypeUnknown} + } + } + return Type{Kind: TypeMap, Arguments: []Type{keyType, valueType}} +} + +func (checker *checker) checkRange(module *Module, currentScope *scope, expression *ast.RangeExpr) Type { + start := checker.checkExpression(module, currentScope, expression.Start) + end := checker.checkExpression(module, currentScope, expression.End) + element := numericType(start, end) + if !start.IsUnknown() && !end.IsUnknown() && element.IsUnknown() { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: range bounds must be numeric, got %s and %s.", + start.String(), end.String())) + } + return Type{Kind: TypeRange, Arguments: []Type{element}} +} + +func (checker *checker) typeMismatch(module *Module, node ast.Node, message string) { + checker.report(module, node, diagnostic.CodeTypeMismatch, message, + "Convert one value or use compatible types.") +} + +func arithmeticMismatch(operator token.Type, left Type, right Type) string { + verb := map[token.Type]string{ + token.Plus: "add", + token.Minus: "subtract", + token.Star: "multiply", + token.Slash: "divide", + token.Percent: "apply modulo to", + }[operator] + return fmt.Sprintf("Type mismatch: cannot %s %s and %s.", verb, left.String(), right.String()) +} + +func operatorText(operator token.Type) string { + switch operator { + case token.And: + return "and" + case token.Or: + return "or" + default: + return string(operator) + } +} diff --git a/src/internal/sema/module.go b/src/internal/sema/module.go index 06048fc..ea1be5d 100644 --- a/src/internal/sema/module.go +++ b/src/internal/sema/module.go @@ -14,9 +14,13 @@ type Import struct { } type Module struct { - Source source.File - Syntax *ast.File - Imports map[string]*Import + Source source.File + Syntax *ast.File + Imports map[string]*Import + Symbols *SymbolTable + ExpressionTypes map[ast.Expression]Type + ResolvedCalls map[*ast.CallExpr]*FunctionSymbol + ResolvedVariables map[*ast.VariableExpr]*VariableSymbol } func (module *Module) Import(prefix string) (*Import, bool) { diff --git a/src/internal/sema/semantic_diagnostic.go b/src/internal/sema/semantic_diagnostic.go new file mode 100644 index 0000000..4b1e509 --- /dev/null +++ b/src/internal/sema/semantic_diagnostic.go @@ -0,0 +1,34 @@ +package sema + +import ( + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +func semanticDiagnostic( + module *Module, + node ast.Node, + code diagnostic.Code, + message string, + hint string, +) diagnostic.Diagnostic { + var span diagnostic.Span + if node != nil { + span = node.Span() + } + + file := "" + if module != nil { + file = module.Source.RelPath + } + + return diagnostic.Diagnostic{ + Code: code, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: message, + Hint: hint, + File: file, + Span: span, + } +} diff --git a/src/internal/sema/statements.go b/src/internal/sema/statements.go new file mode 100644 index 0000000..63fbd65 --- /dev/null +++ b/src/internal/sema/statements.go @@ -0,0 +1,319 @@ +package sema + +import ( + "fmt" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +type flowContext struct { + function *ast.FunctionDecl + returnType Type +} + +func (checker *checker) checkBlock( + module *Module, + currentScope *scope, + block ast.Block, + context flowContext, +) bool { + fallsThrough := true + for _, statement := range block.Statements { + statementFallsThrough := checker.checkStatement(module, currentScope, statement, context) + if fallsThrough { + fallsThrough = statementFallsThrough + } + } + return fallsThrough +} + +func (checker *checker) checkStatement( + module *Module, + currentScope *scope, + statement ast.Statement, + context flowContext, +) bool { + switch statement := statement.(type) { + case *ast.AssignmentStmt: + checker.checkAssignment(module, currentScope, statement) + case *ast.AddStmt: + checker.checkAdd(module, currentScope, statement) + case *ast.IfStmt: + return checker.checkIf(module, currentScope, statement, context) + case *ast.LoopTimesStmt: + if statement == nil { + return true + } + checker.checkLoopTimes(module, currentScope, statement, context) + case *ast.LoopRangeStmt: + if statement == nil { + return true + } + checker.checkLoopRange(module, currentScope, statement, context) + case *ast.LoopPlayersStmt: + if statement == nil { + return true + } + checker.checkLoopPlayers(module, currentScope, statement, context) + case *ast.LoopEntitiesStmt: + if statement == nil { + return true + } + checker.checkLoopEntities(module, currentScope, statement, context) + case *ast.ReturnStmt: + if statement == nil { + return true + } + checker.checkReturn(module, currentScope, statement, context) + return false + case *ast.StopStmt: + if statement == nil { + return true + } + checker.checkStop(module, statement, context) + return false + case *ast.ExprStmt: + if statement == nil { + return true + } + checker.checkExpression(module, currentScope, statement.Expression) + case *ast.EffectStmt: + // Effect internals are raw pattern tokens until T12. + } + return true +} + +func (checker *checker) checkAssignment( + module *Module, + currentScope *scope, + statement *ast.AssignmentStmt, +) { + if statement == nil { + return + } + valueType := checker.checkExpression(module, currentScope, statement.Value) + target := statement.Target + if target == nil { + return + } + checker.checkVariableAccesses(module, currentScope, target) + + if target.Qualifier != nil { + checker.checkImportedAssignment(module, target) + return + } + + if target.Local { + symbol := &VariableSymbol{ + Name: target.Name.Name, + Declaration: statement, + Module: module, + Type: valueType, + Local: true, + } + currentScope.defineLocal(symbol) + module.ResolvedVariables[target] = symbol + return + } + + if _, contextual := currentScope.lookupName(target.Name.Name); contextual { + return + } + + symbol := module.Symbols.Globals[target.Name.Name] + if symbol == nil { + symbol = &VariableSymbol{ + Name: target.Name.Name, + Declaration: statement, + Module: module, + } + module.Symbols.Globals[target.Name.Name] = symbol + } + if len(target.Accesses) == 0 { + symbol.Type = valueType + } + module.ResolvedVariables[target] = symbol +} + +func (checker *checker) checkImportedAssignment(module *Module, target *ast.VariableExpr) { + imported, ok := module.Import(target.Qualifier.Name) + if !ok || imported == nil || imported.Target == nil || imported.Target.Symbols == nil { + checker.undefinedVariable(module, target) + return + } + + symbol := imported.Target.Symbols.Globals[target.Name.Name] + if symbol == nil || !symbol.Public { + checker.undefinedVariable(module, target) + return + } + + module.ResolvedVariables[target] = symbol + checker.report(module, target, diagnostic.CodeAssignToImportedPublicVar, + fmt.Sprintf("Cannot assign to imported public variable: %s", variableName(target)), + fmt.Sprintf("Use a public function like %s.setTax(0.2).", target.Qualifier.Name)) +} + +func (checker *checker) undefinedVariable(module *Module, variable *ast.VariableExpr) { + checker.report(module, variable, diagnostic.CodeUndefinedVariable, + fmt.Sprintf("Undefined variable: %s", variableName(variable)), + fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) +} + +func (checker *checker) checkAdd(module *Module, currentScope *scope, statement *ast.AddStmt) { + if statement == nil { + return + } + checker.checkExpression(module, currentScope, statement.Value) + if target, ok := statement.Target.(*ast.VariableExpr); ok { + checker.checkVariable(module, currentScope, target) + } + // AccessExpr is deliberately deferred to T12. +} + +func (checker *checker) checkIf( + module *Module, + currentScope *scope, + statement *ast.IfStmt, + context flowContext, +) bool { + if statement == nil { + return true + } + checker.requireBool(module, statement.Condition, + checker.checkExpression(module, currentScope, statement.Condition)) + fallsThrough := checker.checkBlock(module, currentScope, statement.Then, context) + + for _, clause := range statement.ElseIf { + checker.requireBool(module, clause.Condition, + checker.checkExpression(module, currentScope, clause.Condition)) + if checker.checkBlock(module, currentScope, clause.Body, context) { + fallsThrough = true + } + } + if statement.Else == nil { + return true + } + if checker.checkBlock(module, currentScope, *statement.Else, context) { + fallsThrough = true + } + return fallsThrough +} + +func (checker *checker) checkLoopTimes( + module *Module, + currentScope *scope, + statement *ast.LoopTimesStmt, + context flowContext, +) { + count := checker.checkExpression(module, currentScope, statement.Count) + checker.requireNumeric(module, statement.Count, count) + loopScope := newInjectedScope(currentScope) + loopScope.defineName("loop.index", Type{Kind: TypeInt}) + checker.checkBlock(module, loopScope, statement.Body, context) +} + +func (checker *checker) checkLoopRange( + module *Module, + currentScope *scope, + statement *ast.LoopRangeStmt, + context flowContext, +) { + start := checker.checkExpression(module, currentScope, statement.Start) + end := checker.checkExpression(module, currentScope, statement.End) + checker.requireNumeric(module, statement.Start, start) + checker.requireNumeric(module, statement.End, end) + + valueType := numericType(start, end) + loopScope := newInjectedScope(currentScope) + loopScope.defineName("loop.index", Type{Kind: TypeInt}) + loopScope.defineName("loop.value", valueType) + checker.checkBlock(module, loopScope, statement.Body, context) +} + +func (checker *checker) checkLoopPlayers( + module *Module, + currentScope *scope, + statement *ast.LoopPlayersStmt, + context flowContext, +) { + loopScope := newInjectedScope(currentScope) + loopScope.defineName("loop.index", Type{Kind: TypeInt}) + loopScope.defineName("loop.player", Type{Kind: TypeNamed, Name: "Player"}) + checker.checkBlock(module, loopScope, statement.Body, context) +} + +func (checker *checker) checkLoopEntities( + module *Module, + currentScope *scope, + statement *ast.LoopEntitiesStmt, + context flowContext, +) { + radius := checker.checkExpression(module, currentScope, statement.Radius) + checker.requireNumeric(module, statement.Radius, radius) + checker.checkExpression(module, currentScope, statement.Around) + + loopScope := newInjectedScope(currentScope) + loopScope.defineName("loop.index", Type{Kind: TypeInt}) + loopScope.defineName("loop.entity", Type{Kind: TypeNamed, Name: "Entity"}) + checker.checkBlock(module, loopScope, statement.Body, context) +} + +func (checker *checker) checkReturn( + module *Module, + currentScope *scope, + statement *ast.ReturnStmt, + context flowContext, +) { + if context.function == nil { + if statement.Value != nil { + checker.checkExpression(module, currentScope, statement.Value) + } + checker.report(module, statement, diagnostic.CodeInvalidReturnOutsideFunction, + "return can only be used inside functions.", + "Use stop to stop an event or execution block.") + return + } + + if statement.Value == nil { + if context.function.ReturnType != nil && !context.returnType.IsUnknown() { + checker.report(module, statement, diagnostic.CodeMissingReturnValue, + "Missing return value.", + fmt.Sprintf("Return a value compatible with %s.", context.returnType.String())) + } + return + } + + actual := checker.checkExpression(module, currentScope, statement.Value) + if context.function.ReturnType != nil && !compatible(context.returnType, actual) { + checker.report(module, statement.Value, diagnostic.CodeTypeMismatch, + fmt.Sprintf("Type mismatch: cannot return %s as %s.", actual.String(), context.returnType.String()), + fmt.Sprintf("Return a value compatible with %s.", context.returnType.String())) + } +} + +func (checker *checker) checkStop(module *Module, statement *ast.StopStmt, context flowContext) { + if context.function == nil || context.function.ReturnType == nil || context.returnType.IsUnknown() { + return + } + checker.report(module, statement, diagnostic.CodeInvalidStopInReturningFunc, + "stop cannot replace a return value.", + fmt.Sprintf("Return a value compatible with %s.", context.returnType.String())) +} + +func (checker *checker) requireBool(module *Module, node ast.Node, typ Type) { + if typ.IsUnknown() || typ.Kind == TypeBool { + return + } + checker.typeMismatch(module, node, + fmt.Sprintf("Type mismatch: condition must be bool, got %s.", typ.String())) +} + +func (checker *checker) requireNumeric(module *Module, node ast.Node, typ Type) { + if typ.IsUnknown() || typ.Kind == TypeInt || typ.Kind == TypeFloat { + return + } + checker.typeMismatch(module, node, + fmt.Sprintf("Type mismatch: expected a number, got %s.", typ.String())) +} diff --git a/src/internal/sema/symbols.go b/src/internal/sema/symbols.go new file mode 100644 index 0000000..38d29ce --- /dev/null +++ b/src/internal/sema/symbols.go @@ -0,0 +1,98 @@ +package sema + +import "github.com/puff-lang/puff/internal/ast" + +type FunctionSymbol struct { + Name string + Declaration *ast.FunctionDecl + Module *Module + Parameters []Type + ReturnType Type + Public bool +} + +type VariableSymbol struct { + Name string + Declaration ast.Node + Module *Module + Type Type + Public bool + Local bool +} + +type SymbolTable struct { + Functions map[string]*FunctionSymbol + Globals map[string]*VariableSymbol +} + +func newSymbolTable() *SymbolTable { + return &SymbolTable{ + Functions: make(map[string]*FunctionSymbol), + Globals: make(map[string]*VariableSymbol), + } +} + +type scope struct { + parent *scope + owner *scope + names map[string]Type + locals map[string]*VariableSymbol +} + +func newExecutionScope() *scope { + current := &scope{ + names: make(map[string]Type), + locals: make(map[string]*VariableSymbol), + } + current.owner = current + return current +} + +func newInjectedScope(parent *scope) *scope { + current := &scope{ + parent: parent, + names: make(map[string]Type), + } + if parent != nil { + current.owner = parent.owner + } + return current +} + +func (current *scope) defineName(name string, typ Type) { + if current != nil { + current.names[name] = typ + } +} + +func (current *scope) lookupName(name string) (Type, bool) { + for candidate := current; candidate != nil; candidate = candidate.parent { + if typ, ok := candidate.names[name]; ok { + return typ, true + } + } + return Type{}, false +} + +func (current *scope) defineLocal(symbol *VariableSymbol) { + if current == nil || symbol == nil { + return + } + owner := current.owner + if owner == nil { + owner = current + } + owner.locals[symbol.Name] = symbol +} + +func (current *scope) lookupLocal(name string) (*VariableSymbol, bool) { + if current == nil { + return nil, false + } + owner := current.owner + if owner == nil { + owner = current + } + symbol, ok := owner.locals[name] + return symbol, ok +} diff --git a/src/internal/sema/types.go b/src/internal/sema/types.go new file mode 100644 index 0000000..6ffd389 --- /dev/null +++ b/src/internal/sema/types.go @@ -0,0 +1,124 @@ +package sema + +import ( + "strings" +) + +type TypeKind string + +const ( + TypeUnknown TypeKind = "unknown" + TypeNil TypeKind = "nil" + TypeBool TypeKind = "bool" + TypeInt TypeKind = "int" + TypeFloat TypeKind = "float" + TypeString TypeKind = "string" + TypeList TypeKind = "list" + TypeMap TypeKind = "map" + TypeRange TypeKind = "range" + TypeFunction TypeKind = "function" + TypeNamed TypeKind = "named" +) + +type Type struct { + Kind TypeKind + Name string + Arguments []Type +} + +func (typ Type) String() string { + name := typ.Name + if name == "" { + name = string(typ.Kind) + } + if len(typ.Arguments) == 0 { + return name + } + + arguments := make([]string, 0, len(typ.Arguments)) + for _, argument := range typ.Arguments { + arguments = append(arguments, argument.String()) + } + return name + "<" + strings.Join(arguments, ", ") + ">" +} + +func (typ Type) IsUnknown() bool { + return typ.Kind == TypeUnknown +} + +var builtInTypes = map[string]TypeKind{ + "nil": TypeNil, + "bool": TypeBool, + "int": TypeInt, + "float": TypeFloat, + "string": TypeString, + "list": TypeList, + "map": TypeMap, + "range": TypeRange, + "function": TypeFunction, + "Player": TypeNamed, + "Entity": TypeNamed, + "Mob": TypeNamed, + "Item": TypeNamed, + "Block": TypeNamed, + "Location": TypeNamed, + "Vector": TypeNamed, + "NBT": TypeNamed, + "Identifier": TypeNamed, + "Score": TypeNamed, + "Objective": TypeNamed, + "Tag": TypeNamed, + "Command": TypeNamed, + "Predicate": TypeNamed, + "Error": TypeNamed, + "TypeError": TypeNamed, + "NameError": TypeNamed, + "SyntaxError": TypeNamed, + "RuntimeError": TypeNamed, + "IndexError": TypeNamed, + "KeyError": TypeNamed, + "ValueError": TypeNamed, +} + +func compatible(expected Type, actual Type) bool { + if expected.IsUnknown() || actual.IsUnknown() { + return true + } + if expected.Kind == TypeFloat && actual.Kind == TypeInt { + return true + } + if expected.Kind != actual.Kind { + return false + } + if expected.Kind == TypeNamed && expected.Name != actual.Name { + return false + } + if len(expected.Arguments) == 0 || len(actual.Arguments) == 0 { + return true + } + if len(expected.Arguments) != len(actual.Arguments) { + return false + } + for index := range expected.Arguments { + if !compatible(expected.Arguments[index], actual.Arguments[index]) { + return false + } + } + return true +} + +func numericType(left Type, right Type) Type { + if left.IsUnknown() || right.IsUnknown() { + return Type{Kind: TypeUnknown} + } + if left.Kind != TypeInt && left.Kind != TypeFloat { + return Type{Kind: TypeUnknown} + } + if right.Kind != TypeInt && right.Kind != TypeFloat { + return Type{Kind: TypeUnknown} + } + if left.Kind == TypeFloat || right.Kind == TypeFloat { + return Type{Kind: TypeFloat} + } + return Type{Kind: TypeInt} +} From 4d9e475e95788e342e9d86fe47e487bc6321ca23 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 18:10:31 -0300 Subject: [PATCH 02/12] test(sema): cover core semantic checks --- src/internal/sema/checker_integration_test.go | 245 +++++++ src/internal/sema/events_flow_test.go | 556 ++++++++++++++++ src/internal/sema/names_types_test.go | 624 ++++++++++++++++++ .../checker/imported-assignment/puff.toml | 5 + .../imported-assignment/src/lib/shop.puff | 1 + .../checker/imported-assignment/src/main.puff | 5 + .../checker/names-types-calls/puff.toml | 5 + .../names-types-calls/src/a_types.puff | 3 + .../names-types-calls/src/b_names.puff | 12 + .../checker/required-events/puff.toml | 5 + .../checker/required-events/src/main.puff | 4 + .../sema/testdata/checker/returns/puff.toml | 5 + .../testdata/checker/returns/src/main.puff | 25 + .../sema/testdata/checker/valid/puff.toml | 5 + .../testdata/checker/valid/src/lib/shop.puff | 5 + .../sema/testdata/checker/valid/src/main.puff | 30 + 16 files changed, 1535 insertions(+) create mode 100644 src/internal/sema/checker_integration_test.go create mode 100644 src/internal/sema/events_flow_test.go create mode 100644 src/internal/sema/names_types_test.go create mode 100644 src/internal/sema/testdata/checker/imported-assignment/puff.toml create mode 100644 src/internal/sema/testdata/checker/imported-assignment/src/lib/shop.puff create mode 100644 src/internal/sema/testdata/checker/imported-assignment/src/main.puff create mode 100644 src/internal/sema/testdata/checker/names-types-calls/puff.toml create mode 100644 src/internal/sema/testdata/checker/names-types-calls/src/a_types.puff create mode 100644 src/internal/sema/testdata/checker/names-types-calls/src/b_names.puff create mode 100644 src/internal/sema/testdata/checker/required-events/puff.toml create mode 100644 src/internal/sema/testdata/checker/required-events/src/main.puff create mode 100644 src/internal/sema/testdata/checker/returns/puff.toml create mode 100644 src/internal/sema/testdata/checker/returns/src/main.puff create mode 100644 src/internal/sema/testdata/checker/valid/puff.toml create mode 100644 src/internal/sema/testdata/checker/valid/src/lib/shop.puff create mode 100644 src/internal/sema/testdata/checker/valid/src/main.puff diff --git a/src/internal/sema/checker_integration_test.go b/src/internal/sema/checker_integration_test.go new file mode 100644 index 0000000..f707690 --- /dev/null +++ b/src/internal/sema/checker_integration_test.go @@ -0,0 +1,245 @@ +package sema + +import ( + "path/filepath" + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/lexer" + "github.com/puff-lang/puff/internal/parser" + "github.com/puff-lang/puff/internal/project" + "github.com/puff-lang/puff/internal/source" +) + +func TestCheckIntegrationAcceptsValidMultiModuleProject(t *testing.T) { + resolved, checked := checkFixture(t, "valid") + + if checked.Project != resolved { + t.Error("expected Check to preserve the resolved project") + } + if len(checked.Diagnostics) != 0 { + t.Fatalf("expected no semantic diagnostics, got %#v", checked.Diagnostics) + } + + main := requireResolvedModule(t, checked.Project, "main.puff") + if imported, ok := main.Import("shop"); !ok || imported.Target.Source.RelPath != "lib/shop.puff" { + t.Fatalf("expected shop to resolve to lib/shop.puff, got %#v", main.Imports) + } +} + +func TestCheckIntegrationReportsDocumentedDiagnosticsWithoutCascades(t *testing.T) { + tests := []struct { + name string + fixture string + expected []expectedSemanticDiagnostic + }{ + { + name: "required metadata events", + fixture: "required-events", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeMissingLoadEvent, + file: "main.puff", + line: 1, + message: "Missing required event: on load", + hint: "Add an on load block or remove the load tag.", + }, + { + code: diagnostic.CodeMissingTickEvent, + file: "main.puff", + line: 1, + message: "Missing required event: on tick", + hint: "Add an on tick block or remove the tick tag.", + }, + }, + }, + { + name: "names types and calls", + fixture: "names-types-calls", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeUndefinedType, + file: "a_types.puff", + line: 1, + message: "Undefined type: MissingType", + }, + { + code: diagnostic.CodeUndefinedVariable, + file: "b_names.puff", + line: 4, + message: "Undefined variable: $missing", + hint: "Declare it before using it: $missing = 0", + }, + { + code: diagnostic.CodeUndefinedFunction, + file: "b_names.puff", + line: 5, + message: "Undefined function: missingFunction", + hint: "Declare fun missingFunction before using it, or import it from a module.", + }, + { + code: diagnostic.CodeMissingArguments, + file: "b_names.puff", + line: 6, + message: "Missing arguments for function: add", + hint: "Call it with parentheses: add(a, b)", + }, + { + code: diagnostic.CodeTooManyArguments, + file: "b_names.puff", + line: 7, + message: "Too many arguments.", + }, + { + code: diagnostic.CodeInvalidArgumentType, + file: "b_names.puff", + line: 8, + message: "Invalid argument type.", + }, + { + code: diagnostic.CodeUndefinedName, + file: "b_names.puff", + line: 11, + message: "Undefined name: player", + hint: "The name \"player\" is only available inside events that inject a player.", + }, + }, + }, + { + name: "imported variable assignment", + fixture: "imported-assignment", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeAssignToImportedPublicVar, + file: "main.puff", + line: 4, + message: "Cannot assign to imported public variable: shop.$tax", + hint: "Use a public function like shop.setTax(0.2).", + }, + }, + }, + { + name: "return and stop distinctions", + fixture: "returns", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeInvalidReturnOutsideFunction, + file: "main.puff", + line: 2, + message: "return can only be used inside functions.", + hint: "Use stop to stop an event or execution block.", + }, + { + code: diagnostic.CodeMissingReturnValue, + file: "main.puff", + line: 14, + message: "Missing return value.", + hint: "Return a value compatible with int.", + }, + { + code: diagnostic.CodeMissingReturn, + file: "main.puff", + line: 17, + message: "Function missingPath must return int in all paths.", + hint: "Add an else branch or a final return.", + }, + { + code: diagnostic.CodeInvalidStopInReturningFunc, + file: "main.puff", + line: 24, + message: "stop cannot replace a return value.", + hint: "Return a value compatible with int.", + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, checked := checkFixture(t, test.fixture) + assertSemanticDiagnostics(t, checked.Diagnostics, test.expected) + }) + } +} + +type expectedSemanticDiagnostic struct { + code diagnostic.Code + file string + line int + message string + hint string +} + +func checkFixture(t *testing.T, name string) (*Project, Result) { + t.Helper() + + root := filepath.Join("testdata", "checker", name) + config, err := project.LoadConfigFromDir(root) + if err != nil { + t.Fatalf("load fixture config: %v", err) + } + loaded, err := source.LoadProject(root, *config) + if err != nil { + t.Fatalf("load fixture sources: %v", err) + } + + syntax := make(map[string]*ast.File, len(loaded.Files)) + for _, file := range loaded.Files { + lexed := lexer.Lex(file) + if len(lexed.Diagnostics) != 0 { + t.Fatalf("lex %s: %#v", file.RelPath, lexed.Diagnostics) + } + parsed := parser.Parse(file, lexed) + if len(parsed.Diagnostics) != 0 { + t.Fatalf("parse %s: %#v", file.RelPath, parsed.Diagnostics) + } + syntax[file.RelPath] = parsed.File + } + + resolved := Resolve(loaded, syntax) + if len(resolved.Diagnostics) != 0 { + t.Fatalf("resolve fixture %s: %#v", name, resolved.Diagnostics) + } + + return resolved.Project, Check(resolved.Project) +} + +func assertSemanticDiagnostics( + t *testing.T, + got []diagnostic.Diagnostic, + expected []expectedSemanticDiagnostic, +) { + t.Helper() + + if len(got) != len(expected) { + t.Fatalf("expected %d diagnostics, got %#v", len(expected), got) + } + for index, want := range expected { + actual := got[index] + if actual.Code != want.code { + t.Errorf("diagnostic %d: expected code %s, got %s", index, want.code, actual.Code) + } + if actual.Phase != diagnostic.PhaseSemantics { + t.Errorf("diagnostic %d: expected semantics phase, got %s", index, actual.Phase) + } + if actual.Severity != diagnostic.SeverityError { + t.Errorf("diagnostic %d: expected error severity, got %s", index, actual.Severity) + } + if actual.File != want.file { + t.Errorf("diagnostic %d: expected file %q, got %q", index, want.file, actual.File) + } + if actual.Span.StartLine != want.line { + t.Errorf("diagnostic %d: expected start line %d, got %#v", index, want.line, actual.Span) + } + if actual.Span.EndOffset <= actual.Span.StartOffset { + t.Errorf("diagnostic %d: expected a non-empty span, got %#v", index, actual.Span) + } + if actual.Message != want.message { + t.Errorf("diagnostic %d: expected message %q, got %q", index, want.message, actual.Message) + } + if actual.Hint != want.hint { + t.Errorf("diagnostic %d: expected hint %q, got %q", index, want.hint, actual.Hint) + } + } +} diff --git a/src/internal/sema/events_flow_test.go b/src/internal/sema/events_flow_test.go new file mode 100644 index 0000000..0e45cd3 --- /dev/null +++ b/src/internal/sema/events_flow_test.go @@ -0,0 +1,556 @@ +package sema + +import ( + "reflect" + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/source" +) + +func TestCheckRequiredEventsFromMetadata(t *testing.T) { + tests := []struct { + name string + tags string + events [][]string + wantCodes []diagnostic.Code + wantMessages []string + wantHints []string + }{ + { + name: "load present", + tags: "load", + events: [][]string{{"load"}}, + }, + { + name: "tick present", + tags: "tick", + events: [][]string{{"tick"}}, + }, + { + name: "both present", + tags: "load, tick", + events: [][]string{{"load"}, {"tick"}}, + }, + { + name: "custom tags have no required event", + tags: "custom:event, minecraft:load", + events: nil, + }, + { + name: "load missing", + tags: "load", + events: [][]string{{"tick"}}, + wantCodes: []diagnostic.Code{diagnostic.CodeMissingLoadEvent}, + wantMessages: []string{"Missing required event: on load"}, + wantHints: []string{"Add an on load block or remove the load tag."}, + }, + { + name: "tick missing", + tags: "tick", + events: [][]string{{"load"}}, + wantCodes: []diagnostic.Code{diagnostic.CodeMissingTickEvent}, + wantMessages: []string{"Missing required event: on tick"}, + wantHints: []string{"Add an on tick block or remove the tick tag."}, + }, + { + name: "both missing", + tags: "load, tick", + events: nil, + wantCodes: []diagnostic.Code{diagnostic.CodeMissingLoadEvent, diagnostic.CodeMissingTickEvent}, + wantMessages: []string{ + "Missing required event: on load", + "Missing required event: on tick", + }, + wantHints: []string{ + "Add an on load block or remove the load tag.", + "Add an on tick block or remove the tick tag.", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + metadata := efMetadata(test.tags, 1) + declarations := make([]ast.Declaration, 0, len(test.events)) + for index, name := range test.events { + declarations = append(declarations, efEvent(name, nil, 10+index)) + } + project := efProject(efModule("events.puff", []ast.MetadataEntry{metadata}, declarations...)) + + result := Check(project) + + want := make([]diagnostic.Diagnostic, len(test.wantCodes)) + for index, code := range test.wantCodes { + want[index] = efDiagnostic( + code, + test.wantMessages[index], + test.wantHints[index], + "events.puff", + metadata.Span(), + ) + } + efAssertDiagnostics(t, result.Diagnostics, want...) + }) + } +} + +func TestCheckRequiredEventsMatchExactNamesPerModule(t *testing.T) { + t.Run("event names are exact and case sensitive", func(t *testing.T) { + metadata := efMetadata("load", 2) + project := efProject(efModule( + "exact.puff", + []ast.MetadataEntry{metadata}, + efEvent([]string{"Load"}, nil, 10), + efEvent([]string{"load", "extra"}, nil, 11), + )) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics, efDiagnostic( + diagnostic.CodeMissingLoadEvent, + "Missing required event: on load", + "Add an on load block or remove the load tag.", + "exact.puff", + metadata.Span(), + )) + }) + + t.Run("another module cannot satisfy the requirement", func(t *testing.T) { + metadata := efMetadata("load", 3) + project := efProject( + efModule("needs-load.puff", []ast.MetadataEntry{metadata}), + efModule("has-load.puff", nil, efEvent([]string{"load"}, nil, 20)), + ) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics, efDiagnostic( + diagnostic.CodeMissingLoadEvent, + "Missing required event: on load", + "Add an on load block or remove the load tag.", + "needs-load.puff", + metadata.Span(), + )) + }) +} + +func TestCheckRejectsReturnInEventsIncludingNestedLoops(t *testing.T) { + tests := []struct { + name string + body []ast.Statement + span diagnostic.Span + }{ + { + name: "direct", + body: []ast.Statement{efReturn(nil, 30)}, + span: efSpan(30), + }, + { + name: "inside loop", + body: []ast.Statement{ + &ast.LoopTimesStmt{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(31)}, + Count: efInt(1, 32), + Body: efBlock(efReturn(nil, 33)), + }, + }, + span: efSpan(33), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + project := efProject(efModule( + "event-return.puff", + nil, + efEvent([]string{"custom"}, test.body, 29), + )) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics, efDiagnostic( + diagnostic.CodeInvalidReturnOutsideFunction, + "return can only be used inside functions.", + "Use stop to stop an event or execution block.", + "event-return.puff", + test.span, + )) + }) + } +} + +func TestCheckReturningFunctionFlow(t *testing.T) { + intType := efType("int", 40) + tests := []struct { + name string + body []ast.Statement + want *diagnostic.Diagnostic + }{ + { + name: "bare return reports missing value without missing return cascade", + body: []ast.Statement{efReturn(nil, 41)}, + want: efDiagnosticPointer( + diagnostic.CodeMissingReturnValue, + "Missing return value.", + "Return a value compatible with int.", + "flow.puff", + efSpan(41), + ), + }, + { + name: "absent return", + body: nil, + want: efDiagnosticPointer( + diagnostic.CodeMissingReturn, + "Function calculate must return int in all paths.", + "Add an else branch or a final return.", + "flow.puff", + efSpan(40), + ), + }, + { + name: "partial if", + body: []ast.Statement{ + efIf( + efBlock(efReturn(efInt(1, 43), 42)), + nil, + nil, + 42, + ), + }, + want: efDiagnosticPointer( + diagnostic.CodeMissingReturn, + "Function calculate must return int in all paths.", + "Add an else branch or a final return.", + "flow.puff", + efSpan(40), + ), + }, + { + name: "if else returns on all paths", + body: []ast.Statement{ + efIf( + efBlock(efReturn(efInt(1, 45), 44)), + nil, + efBlockPointer(efReturn(efInt(2, 47), 46)), + 44, + ), + }, + }, + { + name: "else if and else return on all paths", + body: []ast.Statement{ + efIf( + efBlock(efReturn(efInt(1, 49), 48)), + []ast.ElseIfClause{ + { + NodeBase: ast.NodeBase{SourceSpan: efSpan(50)}, + Condition: efBool(true, 50), + Body: efBlock(efReturn(efInt(2, 52), 51)), + }, + }, + efBlockPointer(efReturn(efInt(3, 54), 53)), + 48, + ), + }, + }, + { + name: "nested if returns on all paths", + body: []ast.Statement{ + efIf( + efBlock(efIf( + efBlock(efReturn(efInt(1, 57), 56)), + nil, + efBlockPointer(efReturn(efInt(2, 59), 58)), + 56, + )), + nil, + efBlockPointer(efReturn(efInt(3, 61), 60)), + 55, + ), + }, + }, + { + name: "partial if followed by final return", + body: []ast.Statement{ + efIf( + efBlock(efReturn(efInt(1, 63), 62)), + nil, + nil, + 62, + ), + efReturn(efInt(2, 65), 64), + }, + }, + { + name: "return only inside loop does not guarantee return", + body: []ast.Statement{ + &ast.LoopTimesStmt{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(66)}, + Count: efInt(1, 66), + Body: efBlock(efReturn(efInt(1, 68), 67)), + }, + }, + want: efDiagnosticPointer( + diagnostic.CodeMissingReturn, + "Function calculate must return int in all paths.", + "Add an else branch or a final return.", + "flow.puff", + efSpan(40), + ), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + function := efFunction("calculate", intType, test.body, 40) + project := efProject(efModule("flow.puff", nil, function)) + + result := Check(project) + + if test.want == nil { + efAssertDiagnostics(t, result.Diagnostics) + return + } + efAssertDiagnostics(t, result.Diagnostics, *test.want) + }) + } +} + +func TestCheckStopRules(t *testing.T) { + t.Run("stop is valid in event", func(t *testing.T) { + project := efProject(efModule( + "stop-event.puff", + nil, + efEvent([]string{"custom"}, []ast.Statement{efStop(70)}, 69), + )) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics) + }) + + t.Run("stop is valid in untyped function", func(t *testing.T) { + project := efProject(efModule( + "stop-function.puff", + nil, + efFunction("setup", nil, []ast.Statement{efStop(72)}, 71), + )) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics) + }) + + t.Run("stop in typed function has no missing return cascade", func(t *testing.T) { + project := efProject(efModule( + "stop-returning.puff", + nil, + efFunction("calculate", efType("int", 73), []ast.Statement{efStop(74)}, 73), + )) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics, efDiagnostic( + diagnostic.CodeInvalidStopInReturningFunc, + "stop cannot replace a return value.", + "Return a value compatible with int.", + "stop-returning.puff", + efSpan(74), + )) + }) +} + +func TestCheckRejectsIncompatibleReturnType(t *testing.T) { + value := &ast.StringExpr{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(81)}, + Quote: '"', + Parts: []ast.StringPart{ + &ast.StringText{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(81)}, + Raw: "wrong", + Value: "wrong", + }, + }, + } + project := efProject(efModule( + "return-type.puff", + nil, + efFunction( + "calculate", + efType("int", 80), + []ast.Statement{efReturn(value, 80)}, + 80, + ), + )) + + result := Check(project) + + efAssertDiagnostics(t, result.Diagnostics, efDiagnostic( + diagnostic.CodeTypeMismatch, + "Type mismatch: cannot return string as int.", + "Return a value compatible with int.", + "return-type.puff", + value.Span(), + )) +} + +func efProject(modules ...*Module) *Project { + return &Project{Root: "/project", Modules: modules} +} + +func efModule(relPath string, metadata []ast.MetadataEntry, declarations ...ast.Declaration) *Module { + return &Module{ + Source: source.NewFile("/project/src/"+relPath, relPath, ""), + Syntax: &ast.File{ + Metadata: metadata, + Declarations: declarations, + }, + Imports: map[string]*Import{}, + } +} + +func efMetadata(tags string, seed int) ast.MetadataEntry { + return ast.MetadataEntry{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Key: "tags", + Value: tags, + } +} + +func efEvent(name []string, statements []ast.Statement, seed int) *ast.EventDecl { + identifiers := make([]ast.Identifier, len(name)) + for index, part := range name { + identifiers[index] = ast.Identifier{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed + index)}, + Name: part, + } + } + return &ast.EventDecl{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Name: identifiers, + Body: efBlock(statements...), + } +} + +func efFunction(name string, returnType *ast.TypeRef, statements []ast.Statement, seed int) *ast.FunctionDecl { + return &ast.FunctionDecl{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Name: ast.Identifier{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Name: name, + }, + ReturnType: returnType, + Body: efBlock(statements...), + } +} + +func efType(name string, seed int) *ast.TypeRef { + return &ast.TypeRef{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Name: ast.Identifier{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Name: name, + }, + } +} + +func efBlock(statements ...ast.Statement) ast.Block { + return ast.Block{Statements: statements} +} + +func efBlockPointer(statements ...ast.Statement) *ast.Block { + value := efBlock(statements...) + return &value +} + +func efIf(then ast.Block, elseIf []ast.ElseIfClause, elseBlock *ast.Block, seed int) *ast.IfStmt { + return &ast.IfStmt{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Condition: efBool(true, seed), + Then: then, + ElseIf: elseIf, + Else: elseBlock, + } +} + +func efReturn(value ast.Expression, seed int) *ast.ReturnStmt { + return &ast.ReturnStmt{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Value: value, + } +} + +func efStop(seed int) *ast.StopStmt { + return &ast.StopStmt{NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}} +} + +func efInt(value int64, seed int) *ast.IntLiteral { + return &ast.IntLiteral{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Value: value, + } +} + +func efBool(value bool, seed int) *ast.BoolLiteral { + return &ast.BoolLiteral{ + NodeBase: ast.NodeBase{SourceSpan: efSpan(seed)}, + Value: value, + } +} + +func efSpan(seed int) diagnostic.Span { + return diagnostic.Span{ + StartLine: seed, + StartColumn: 2, + EndLine: seed, + EndColumn: 8, + StartOffset: seed * 10, + EndOffset: seed*10 + 6, + } +} + +func efDiagnostic( + code diagnostic.Code, + message string, + hint string, + file string, + span diagnostic.Span, +) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Code: code, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: message, + Hint: hint, + File: file, + Span: span, + } +} + +func efDiagnosticPointer( + code diagnostic.Code, + message string, + hint string, + file string, + span diagnostic.Span, +) *diagnostic.Diagnostic { + value := efDiagnostic(code, message, hint, file, span) + return &value +} + +func efAssertDiagnostics( + t *testing.T, + got []diagnostic.Diagnostic, + want ...diagnostic.Diagnostic, +) { + t.Helper() + if len(got) == 0 && len(want) == 0 { + return + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected diagnostics:\ngot %#v\nwant %#v", got, want) + } +} diff --git a/src/internal/sema/names_types_test.go b/src/internal/sema/names_types_test.go new file mode 100644 index 0000000..8d7894b --- /dev/null +++ b/src/internal/sema/names_types_test.go @@ -0,0 +1,624 @@ +package sema + +import ( + "reflect" + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/source" + "github.com/puff-lang/puff/internal/token" +) + +func TestCheckResolvesModuleGlobalsLocalsAndParameters(t *testing.T) { + global := nttVariable("coins", false, 1) + localTarget := nttVariable("price", true, 4) + localRead := nttVariable("price", true, 5) + parameterRead := nttCall("amount", false, 6) + globalRead := nttVariable("coins", false, 7) + module := nttModule("main.puff", + &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: global, + Value: nttInt(100, 1), + }, + &ast.FunctionDecl{ + NodeBase: nttBase(3), + Name: nttIdentifier("calculate", 3), + Parameters: []ast.Parameter{{ + NodeBase: nttBase(3), + Name: nttIdentifier("amount", 3), + }}, + Body: ast.Block{Statements: []ast.Statement{ + &ast.AssignmentStmt{NodeBase: nttBase(4), Target: localTarget, Value: nttInt(50, 4)}, + &ast.ExprStmt{NodeBase: nttBase(5), Expression: localRead}, + &ast.ExprStmt{NodeBase: nttBase(6), Expression: parameterRead}, + &ast.ExprStmt{NodeBase: nttBase(7), Expression: globalRead}, + }}, + }, + ) + + project := nttProject(module) + result := Check(project) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if result.Project != project { + t.Fatal("Check must preserve the input project graph") + } + if module.Symbols == nil || module.Symbols.Functions["calculate"] == nil || + module.Symbols.Globals["coins"] == nil { + t.Fatalf("expected module symbols to be indexed, got %#v", module.Symbols) + } + if symbol := module.ResolvedVariables[localRead]; symbol == nil || !symbol.Local { + t.Fatalf("expected local read to resolve to a local symbol, got %#v", symbol) + } + if symbol := module.ResolvedVariables[globalRead]; symbol == nil || symbol.Local { + t.Fatalf("expected global read to resolve to a global symbol, got %#v", symbol) + } + if typ := module.ExpressionTypes[localRead]; typ.Kind != TypeInt { + t.Fatalf("expected local read type int, got %#v", typ) + } + if _, resolvedAsFunction := module.ResolvedCalls[parameterRead]; resolvedAsFunction { + t.Fatal("parameter value must not resolve as a function") + } +} + +func TestCheckKeepsLocalsInsideTheirExecutionScope(t *testing.T) { + leakedRead := nttVariable("price", true, 8) + module := nttModule("main.puff", + &ast.FunctionDecl{ + Name: nttIdentifier("first", 2), + Body: ast.Block{Statements: []ast.Statement{ + &ast.AssignmentStmt{ + NodeBase: nttBase(3), + Target: nttVariable("price", true, 3), + Value: nttInt(50, 3), + }, + }}, + }, + &ast.FunctionDecl{ + Name: nttIdentifier("second", 7), + Body: ast.Block{Statements: []ast.Statement{ + &ast.ExprStmt{NodeBase: nttBase(8), Expression: leakedRead}, + }}, + }, + ) + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: $_price", + Hint: "Declare it before using it: $_price = 0", + File: "main.puff", + Span: leakedRead.Span(), + }) +} + +func TestCheckReportsUndefinedSymbolsWithoutCascades(t *testing.T) { + tests := []struct { + name string + decl ast.Declaration + want diagnostic.Diagnostic + }{ + { + name: "global variable", + decl: nttEvent("load", &ast.ExprStmt{ + NodeBase: nttBase(4), + Expression: nttVariable("coins", false, 4), + }), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Message: "Undefined variable: $coins", + Hint: "Declare it before using it: $coins = 0", + Span: nttSpan(4), + }, + }, + { + name: "function", + decl: nttEvent("load", &ast.ExprStmt{ + NodeBase: nttBase(5), + Expression: nttCall("setupShop", false, 5), + }), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedFunction, + Message: "Undefined function: setupShop", + Hint: "Declare fun setupShop before using it, or import it from a module.", + Span: nttSpan(5), + }, + }, + { + name: "context name", + decl: nttEvent("load", &ast.ExprStmt{ + NodeBase: nttBase(6), + Expression: nttQualifiedCall([]string{"loop", "index"}, false, 6), + }), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedName, + Message: "Undefined name: loop.index", + Hint: "Declare the name before using it.", + Span: nttSpan(6), + }, + }, + { + name: "type", + decl: &ast.FunctionDecl{ + Name: nttIdentifier("lookup", 7), + Parameters: []ast.Parameter{{ + NodeBase: nttBase(7), + Name: nttIdentifier("value", 7), + Type: nttType("UnknownType", 7), + }}, + }, + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedType, + Message: "Undefined type: UnknownType", + Span: nttSpan(7), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.want.Phase = diagnostic.PhaseSemantics + test.want.Severity = diagnostic.SeverityError + test.want.File = "main.puff" + + result := Check(nttProject(nttModule("main.puff", test.decl))) + + nttAssertDiagnostic(t, result.Diagnostics, test.want) + }) + } +} + +func TestCheckAcceptsDocumentedBuiltInAndNestedGenericTypes(t *testing.T) { + builtIns := []string{ + "nil", "bool", "int", "float", "string", + "list", "map", "range", + "Player", "Entity", "Mob", "Item", "Block", "Location", "Vector", "NBT", + "Identifier", "Score", "Objective", "Tag", "Command", "Predicate", + "function", + "Error", "TypeError", "NameError", "SyntaxError", "RuntimeError", + "IndexError", "KeyError", "ValueError", + } + parameters := make([]ast.Parameter, 0, len(builtIns)+1) + for index, name := range builtIns { + line := index + 1 + parameters = append(parameters, ast.Parameter{ + NodeBase: nttBase(line), + Name: nttIdentifier("value"+name, line), + Type: nttType(name, line), + }) + } + parameters = append(parameters, ast.Parameter{ + NodeBase: nttBase(40), + Name: nttIdentifier("nested", 40), + Type: nttGenericType("map", 40, + nttType("string", 40), + nttGenericType("list", 40, nttType("int", 40)), + ), + }) + + result := Check(nttProject(nttModule("types.puff", &ast.FunctionDecl{ + Name: nttIdentifier("acceptTypes", 1), + Parameters: parameters, + }))) + + nttAssertNoDiagnostics(t, result.Diagnostics) +} + +func TestCheckResolvesLocalAndImportedSymbolsByVisibility(t *testing.T) { + target := nttModule("abc/shop.puff", + nttFunction("publicPrice", true, nil, 1), + nttFunction("privatePrice", false, nil, 2), + &ast.GlobalAssignment{ + Public: true, + NodeBase: nttBase(3), + Target: nttVariable("tax", false, 3), + Value: nttFloat(0.1, 3), + }, + &ast.GlobalAssignment{ + NodeBase: nttBase(4), + Target: nttVariable("secret", false, 4), + Value: nttInt(1, 4), + }, + ) + importedCall := nttQualifiedCall([]string{"economy", "publicPrice"}, true, 7) + importedVariable := nttImportedVariable("economy", "tax", 8) + main := nttModule("main.puff", + nttFunction("localPrivate", false, nil, 1), + nttFunction("localPublic", true, nil, 2), + nttEvent("load", + nttExprStmt(nttCall("localPrivate", false, 5), 5), + nttExprStmt(nttCall("localPublic", false, 6), 6), + nttExprStmt(importedCall, 7), + nttExprStmt(importedVariable, 8), + ), + ) + main.Imports["economy"] = &Import{Path: "abc/shop", Prefix: "economy", Target: target} + + result := Check(nttProject(main, target)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if symbol := main.ResolvedCalls[importedCall]; symbol == nil || + symbol.Module != target || symbol.Name != "publicPrice" || !symbol.Public { + t.Fatalf("expected alias-qualified public function resolution, got %#v", symbol) + } + if symbol := main.ResolvedVariables[importedVariable]; symbol == nil || + symbol.Module != target || symbol.Name != "tax" || !symbol.Public { + t.Fatalf("expected alias-qualified public variable resolution, got %#v", symbol) + } +} + +func TestCheckDoesNotExposeImportedSymbolsUnqualifiedOrPrivately(t *testing.T) { + tests := []struct { + name string + expr ast.Expression + want diagnostic.Diagnostic + }{ + { + name: "unqualified public function", + expr: nttCall("publicPrice", false, 10), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedFunction, + Message: "Undefined function: publicPrice", + Hint: "Declare fun publicPrice before using it, or import it from a module.", + Span: nttSpan(10), + }, + }, + { + name: "unqualified public variable", + expr: nttVariable("tax", false, 11), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Message: "Undefined variable: $tax", + Hint: "Declare it before using it: $tax = 0", + Span: nttSpan(11), + }, + }, + { + name: "private imported function", + expr: nttQualifiedCall([]string{"shop", "privatePrice"}, true, 12), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedFunction, + Message: "Undefined function: shop.privatePrice", + Hint: "Declare fun shop.privatePrice before using it, or import it from a module.", + Span: nttSpan(12), + }, + }, + { + name: "private imported variable", + expr: nttImportedVariable("shop", "secret", 13), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Message: "Undefined variable: shop.$secret", + Hint: "Declare it before using it: shop.$secret = 0", + Span: nttSpan(13), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + target := nttModule("abc/shop.puff", + nttFunction("publicPrice", true, nil, 1), + nttFunction("privatePrice", false, nil, 2), + &ast.GlobalAssignment{Public: true, Target: nttVariable("tax", false, 3), Value: nttInt(1, 3)}, + &ast.GlobalAssignment{Target: nttVariable("secret", false, 4), Value: nttInt(1, 4)}, + ) + main := nttModule("main.puff", nttEvent("load", nttExprStmt(test.expr, test.expr.Span().StartLine))) + main.Imports["shop"] = &Import{Path: "abc/shop", Prefix: "shop", Target: target} + test.want.Phase = diagnostic.PhaseSemantics + test.want.Severity = diagnostic.SeverityError + test.want.File = "main.puff" + + result := Check(nttProject(main, target)) + + nttAssertDiagnostic(t, result.Diagnostics, test.want) + }) + } +} + +func TestCheckRejectsAssignmentToImportedPublicVariable(t *testing.T) { + target := nttModule("abc/shop.puff", &ast.GlobalAssignment{ + Public: true, + Target: nttVariable("tax", false, 1), + Value: nttFloat(0.1, 1), + }) + imported := nttImportedVariable("shop", "tax", 9) + main := nttModule("main.puff", nttEvent("load", &ast.AssignmentStmt{ + NodeBase: nttBase(9), + Target: imported, + Value: nttFloat(0.2, 9), + })) + main.Imports["shop"] = &Import{Path: "abc/shop", Prefix: "shop", Target: target} + + result := Check(nttProject(main, target)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeAssignToImportedPublicVar, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Cannot assign to imported public variable: shop.$tax", + Hint: "Use a public function like shop.setTax(0.2).", + File: "main.puff", + Span: imported.Span(), + }) +} + +func TestCheckRejectsPublicLocalVariableAST(t *testing.T) { + local := nttVariable("price", true, 3) + module := nttModule("main.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(3), + Public: true, + Target: local, + Value: nttInt(50, 3), + }) + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeInvalidPublicLocalVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Local variables cannot be public.", + Hint: "Only global variables can be exported.", + File: "main.puff", + Span: local.Span(), + }) +} + +func TestCheckValidatesFunctionArguments(t *testing.T) { + parameter := ast.Parameter{ + NodeBase: nttBase(1), + Name: nttIdentifier("amount", 1), + Type: nttType("int", 1), + } + tests := []struct { + name string + call *ast.CallExpr + want diagnostic.Diagnostic + }{ + { + name: "missing parentheses and arguments", + call: nttCall("reward", false, 5), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeMissingArguments, + Message: "Missing arguments for function: reward", + Hint: "Call it with parentheses: reward(amount)", + Span: nttSpan(5), + }, + }, + { + name: "too many", + call: nttCallWithArgs("reward", 6, nttInt(1, 6), nttInt(2, 6)), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeTooManyArguments, + Message: "Too many arguments.", + Span: nttSpan(6), + }, + }, + { + name: "wrong type", + call: nttCallWithArgs("reward", 7, nttString("many", 7)), + want: diagnostic.Diagnostic{ + Code: diagnostic.CodeInvalidArgumentType, + Message: "Invalid argument type.", + Span: nttSpan(7), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := nttModule("main.puff", + nttFunction("reward", false, []ast.Parameter{parameter}, 1), + nttEvent("load", nttExprStmt(test.call, test.call.Span().StartLine)), + ) + test.want.Phase = diagnostic.PhaseSemantics + test.want.Severity = diagnostic.SeverityError + test.want.File = "main.puff" + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, test.want) + }) + } +} + +func TestCheckReportsExactAdditionTypeMismatch(t *testing.T) { + expression := &ast.BinaryExpr{ + NodeBase: nttBase(4), + Left: nttInt(1, 4), + Operator: token.Plus, + Right: nttString("one", 4), + } + module := nttModule("main.puff", &ast.GlobalAssignment{ + Target: nttVariable("total", false, 4), + Value: expression, + }) + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot add int and string.", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: expression.Span(), + }) +} + +func TestCheckAcceptsNumericCompatibility(t *testing.T) { + expressions := []ast.Expression{ + &ast.BinaryExpr{NodeBase: nttBase(1), Left: nttInt(1, 1), Operator: token.Plus, Right: nttInt(2, 1)}, + &ast.BinaryExpr{NodeBase: nttBase(2), Left: nttInt(1, 2), Operator: token.Plus, Right: nttFloat(2.5, 2)}, + &ast.BinaryExpr{NodeBase: nttBase(3), Left: nttFloat(1.5, 3), Operator: token.Plus, Right: nttInt(2, 3)}, + &ast.BinaryExpr{NodeBase: nttBase(4), Left: nttFloat(1.5, 4), Operator: token.Plus, Right: nttFloat(2.5, 4)}, + } + declarations := make([]ast.Declaration, 0, len(expressions)) + for index, expression := range expressions { + declarations = append(declarations, &ast.GlobalAssignment{ + Target: nttVariable("number", false, index+1), + Value: expression, + }) + } + + result := Check(nttProject(nttModule("main.puff", declarations...))) + + nttAssertNoDiagnostics(t, result.Diagnostics) + module := result.Project.Modules[0] + wantKinds := []TypeKind{TypeInt, TypeFloat, TypeFloat, TypeFloat} + for index, expression := range expressions { + if got := module.ExpressionTypes[expression].Kind; got != wantKinds[index] { + t.Fatalf("expression %d: expected type %s, got %s", index, wantKinds[index], got) + } + } +} + +func nttProject(modules ...*Module) *Project { + return &Project{Root: "/project", Modules: modules} +} + +func nttModule(relPath string, declarations ...ast.Declaration) *Module { + return &Module{ + Source: source.NewFile("/project/src/"+relPath, relPath, ""), + Syntax: &ast.File{Declarations: declarations}, + Imports: map[string]*Import{}, + } +} + +func nttFunction(name string, public bool, parameters []ast.Parameter, line int) *ast.FunctionDecl { + return &ast.FunctionDecl{ + NodeBase: nttBase(line), + Public: public, + Name: nttIdentifier(name, line), + Parameters: parameters, + } +} + +func nttEvent(name string, statements ...ast.Statement) *ast.EventDecl { + return &ast.EventDecl{ + NodeBase: nttBase(2), + Name: []ast.Identifier{nttIdentifier(name, 2)}, + Body: ast.Block{Statements: statements}, + } +} + +func nttExprStmt(expression ast.Expression, line int) *ast.ExprStmt { + return &ast.ExprStmt{NodeBase: nttBase(line), Expression: expression} +} + +func nttVariable(name string, local bool, line int) *ast.VariableExpr { + return &ast.VariableExpr{ + NodeBase: nttBase(line), + Name: nttIdentifier(name, line), + Local: local, + } +} + +func nttImportedVariable(prefix string, name string, line int) *ast.VariableExpr { + qualifier := nttIdentifier(prefix, line) + return &ast.VariableExpr{ + NodeBase: nttBase(line), + Qualifier: &qualifier, + Name: nttIdentifier(name, line), + } +} + +func nttCall(name string, explicit bool, line int) *ast.CallExpr { + return nttQualifiedCall([]string{name}, explicit, line) +} + +func nttCallWithArgs(name string, line int, arguments ...ast.Expression) *ast.CallExpr { + call := nttCall(name, true, line) + call.Arguments = arguments + return call +} + +func nttQualifiedCall(parts []string, explicit bool, line int) *ast.CallExpr { + identifiers := make([]ast.Identifier, 0, len(parts)) + for _, part := range parts { + identifiers = append(identifiers, nttIdentifier(part, line)) + } + return &ast.CallExpr{ + NodeBase: nttBase(line), + Callee: ast.QualifiedName{NodeBase: nttBase(line), Parts: identifiers}, + ExplicitParens: explicit, + } +} + +func nttType(name string, line int) *ast.TypeRef { + return &ast.TypeRef{ + NodeBase: nttBase(line), + Name: nttIdentifier(name, line), + } +} + +func nttGenericType(name string, line int, arguments ...*ast.TypeRef) *ast.TypeRef { + typeRef := nttType(name, line) + typeRef.Arguments = arguments + return typeRef +} + +func nttInt(value int64, line int) *ast.IntLiteral { + return &ast.IntLiteral{NodeBase: nttBase(line), Value: value} +} + +func nttFloat(value float64, line int) *ast.FloatLiteral { + return &ast.FloatLiteral{NodeBase: nttBase(line), Value: value} +} + +func nttString(value string, line int) *ast.StringExpr { + return &ast.StringExpr{ + NodeBase: nttBase(line), + Quote: '"', + Parts: []ast.StringPart{ + &ast.StringText{NodeBase: nttBase(line), Raw: value, Value: value}, + }, + } +} + +func nttIdentifier(name string, line int) ast.Identifier { + return ast.Identifier{NodeBase: nttBase(line), Name: name} +} + +func nttBase(line int) ast.NodeBase { + return ast.NodeBase{SourceSpan: nttSpan(line)} +} + +func nttSpan(line int) diagnostic.Span { + return diagnostic.Span{ + StartLine: line, + StartColumn: 3, + EndLine: line, + EndColumn: 12, + StartOffset: line * 20, + EndOffset: line*20 + 9, + } +} + +func nttAssertNoDiagnostics(t *testing.T, diagnostics []diagnostic.Diagnostic) { + t.Helper() + if len(diagnostics) != 0 { + t.Fatalf("expected no diagnostics, got %#v", diagnostics) + } +} + +func nttAssertDiagnostic( + t *testing.T, + diagnostics []diagnostic.Diagnostic, + want diagnostic.Diagnostic, +) { + t.Helper() + if len(diagnostics) != 1 { + t.Fatalf("expected exactly one diagnostic without cascades, got %#v", diagnostics) + } + if !reflect.DeepEqual(diagnostics[0], want) { + t.Fatalf("unexpected diagnostic:\ngot %#v\nwant %#v", diagnostics[0], want) + } +} diff --git a/src/internal/sema/testdata/checker/imported-assignment/puff.toml b/src/internal/sema/testdata/checker/imported-assignment/puff.toml new file mode 100644 index 0000000..775c3a5 --- /dev/null +++ b/src/internal/sema/testdata/checker/imported-assignment/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-imported-assignment" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/imported-assignment/src/lib/shop.puff b/src/internal/sema/testdata/checker/imported-assignment/src/lib/shop.puff new file mode 100644 index 0000000..fe70b45 --- /dev/null +++ b/src/internal/sema/testdata/checker/imported-assignment/src/lib/shop.puff @@ -0,0 +1 @@ +pub $tax = 0.1 diff --git a/src/internal/sema/testdata/checker/imported-assignment/src/main.puff b/src/internal/sema/testdata/checker/imported-assignment/src/main.puff new file mode 100644 index 0000000..1c8e23e --- /dev/null +++ b/src/internal/sema/testdata/checker/imported-assignment/src/main.puff @@ -0,0 +1,5 @@ +require "lib/shop" + +on load + shop.$tax = 0.2 +end diff --git a/src/internal/sema/testdata/checker/names-types-calls/puff.toml b/src/internal/sema/testdata/checker/names-types-calls/puff.toml new file mode 100644 index 0000000..a3c2973 --- /dev/null +++ b/src/internal/sema/testdata/checker/names-types-calls/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-names-types-calls" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/names-types-calls/src/a_types.puff b/src/internal/sema/testdata/checker/names-types-calls/src/a_types.puff new file mode 100644 index 0000000..a3bad15 --- /dev/null +++ b/src/internal/sema/testdata/checker/names-types-calls/src/a_types.puff @@ -0,0 +1,3 @@ +fun typed(value: MissingType) -> int + return 1 +end diff --git a/src/internal/sema/testdata/checker/names-types-calls/src/b_names.puff b/src/internal/sema/testdata/checker/names-types-calls/src/b_names.puff new file mode 100644 index 0000000..ca6e357 --- /dev/null +++ b/src/internal/sema/testdata/checker/names-types-calls/src/b_names.puff @@ -0,0 +1,12 @@ +fun add(a: int, b: int) -> int + return a + b +end +$variable = $missing +$unknown = missingFunction() +$without_arguments = add +$too_many = add(1, 2, 3) +$wrong_type = add("wrong", 2) + +on load + $_context = player +end diff --git a/src/internal/sema/testdata/checker/required-events/puff.toml b/src/internal/sema/testdata/checker/required-events/puff.toml new file mode 100644 index 0000000..ec689eb --- /dev/null +++ b/src/internal/sema/testdata/checker/required-events/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-required-events" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/required-events/src/main.puff b/src/internal/sema/testdata/checker/required-events/src/main.puff new file mode 100644 index 0000000..3f3a410 --- /dev/null +++ b/src/internal/sema/testdata/checker/required-events/src/main.puff @@ -0,0 +1,4 @@ +# tags: load, tick + +on join +end diff --git a/src/internal/sema/testdata/checker/returns/puff.toml b/src/internal/sema/testdata/checker/returns/puff.toml new file mode 100644 index 0000000..1501d60 --- /dev/null +++ b/src/internal/sema/testdata/checker/returns/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-returns" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/returns/src/main.puff b/src/internal/sema/testdata/checker/returns/src/main.puff new file mode 100644 index 0000000..30e272a --- /dev/null +++ b/src/internal/sema/testdata/checker/returns/src/main.puff @@ -0,0 +1,25 @@ +on load + return +end + +on tick + stop +end + +fun stopAllowed + stop +end + +fun missingValue -> int + return +end + +fun missingPath(flag: bool) -> int + if flag + return 1 + end +end + +fun invalidStop -> int + stop +end diff --git a/src/internal/sema/testdata/checker/valid/puff.toml b/src/internal/sema/testdata/checker/valid/puff.toml new file mode 100644 index 0000000..e1c8fde --- /dev/null +++ b/src/internal/sema/testdata/checker/valid/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-valid" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/valid/src/lib/shop.puff b/src/internal/sema/testdata/checker/valid/src/lib/shop.puff new file mode 100644 index 0000000..22c8bf3 --- /dev/null +++ b/src/internal/sema/testdata/checker/valid/src/lib/shop.puff @@ -0,0 +1,5 @@ +pub $tax = 0.1 + +pub fun finalPrice(price: float) -> float + return price + $tax +end diff --git a/src/internal/sema/testdata/checker/valid/src/main.puff b/src/internal/sema/testdata/checker/valid/src/main.puff new file mode 100644 index 0000000..02bc6f2 --- /dev/null +++ b/src/internal/sema/testdata/checker/valid/src/main.puff @@ -0,0 +1,30 @@ +# tags: load, tick + +require "lib/shop" + +$base = 2 + +fun choose(flag: bool) -> int + if flag + return 1 + else + return $base + end +end + +on load + $_tax = shop.$tax + $_total = shop.finalPrice(10.0) + $_copy = $_total + + loop numbers from 1 to 2 + $_line = "Index {loop.index}: {loop.value}" + end +end + +on tick +end + +on join + $_joined = player +end From 9ffacbe6236e83eac0b7a0b64995a6ab55919ca9 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 18:19:56 -0300 Subject: [PATCH 03/12] fix(sema): enforce semantic edge cases --- src/internal/parser/top_level.go | 2 +- src/internal/sema/checker.go | 1 + src/internal/sema/expressions.go | 47 ++++++++++++++++++++++++++------ src/internal/sema/statements.go | 7 +++++ src/internal/sema/symbols.go | 1 + 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/internal/parser/top_level.go b/src/internal/parser/top_level.go index d1b876f..3848699 100644 --- a/src/internal/parser/top_level.go +++ b/src/internal/parser/top_level.go @@ -162,7 +162,7 @@ func (parser *parser) parseGlobal(public bool) *ast.GlobalAssignment { parser.advance() } target, _ := parser.parseVariable(nil).(*ast.VariableExpr) - if target != nil && target.Local && target.Name.Name != "" { + if target != nil && target.Local && target.Name.Name != "" && !public { parser.report( diagnostic.CodeInvalidTopLevelStatement, "Executable statements are not allowed at the top level.", diff --git a/src/internal/sema/checker.go b/src/internal/sema/checker.go index cacee52..96a595c 100644 --- a/src/internal/sema/checker.go +++ b/src/internal/sema/checker.go @@ -167,6 +167,7 @@ func (checker *checker) checkGlobalInitializers(module *Module) { } if symbol, ok := module.Symbols.Globals[global.Target.Name.Name]; ok { symbol.Type = typ + symbol.initialized = true module.ResolvedVariables[global.Target] = symbol } checker.checkVariableAccesses(module, nil, global.Target) diff --git a/src/internal/sema/expressions.go b/src/internal/sema/expressions.go index 57a6ecb..7b18633 100644 --- a/src/internal/sema/expressions.go +++ b/src/internal/sema/expressions.go @@ -291,6 +291,13 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) return Type{Kind: TypeUnknown} } + if _, isGlobalDeclaration := symbol.Declaration.(*ast.GlobalAssignment); isGlobalDeclaration && + symbol.Module == module && !symbol.initialized { + checker.report(module, variable, diagnostic.CodeUndefinedVariable, + fmt.Sprintf("Undefined variable: %s", variableName(variable)), + fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) + return Type{Kind: TypeUnknown} + } module.ResolvedVariables[variable] = symbol return checker.typeAfterAccesses(symbol.Type, variable.Accesses) @@ -348,8 +355,8 @@ func (checker *checker) checkList(module *Module, currentScope *scope, expressio current := checker.checkExpression(module, currentScope, element) if index == 0 { elementType = current - } else if !compatible(elementType, current) && !compatible(current, elementType) { - elementType = Type{Kind: TypeUnknown} + } else { + elementType = mergeInferredTypes(elementType, current) } } return Type{Kind: TypeList, Arguments: []Type{elementType}} @@ -366,16 +373,40 @@ func (checker *checker) checkMap(module *Module, currentScope *scope, expression valueType = value continue } - if !compatible(keyType, key) && !compatible(key, keyType) { - keyType = Type{Kind: TypeUnknown} - } - if !compatible(valueType, value) && !compatible(value, valueType) { - valueType = Type{Kind: TypeUnknown} - } + keyType = mergeInferredTypes(keyType, key) + valueType = mergeInferredTypes(valueType, value) } return Type{Kind: TypeMap, Arguments: []Type{keyType, valueType}} } +func mergeInferredTypes(left Type, right Type) Type { + if left.IsUnknown() || right.IsUnknown() { + return Type{Kind: TypeUnknown} + } + if numeric := numericType(left, right); !numeric.IsUnknown() { + return numeric + } + if left.Kind != right.Kind || left.Kind == TypeNamed && left.Name != right.Name { + return Type{Kind: TypeUnknown} + } + if len(left.Arguments) == 0 { + return right + } + if len(right.Arguments) == 0 { + return left + } + if len(left.Arguments) != len(right.Arguments) { + return Type{Kind: TypeUnknown} + } + + merged := left + merged.Arguments = make([]Type, len(left.Arguments)) + for index := range left.Arguments { + merged.Arguments[index] = mergeInferredTypes(left.Arguments[index], right.Arguments[index]) + } + return merged +} + func (checker *checker) checkRange(module *Module, currentScope *scope, expression *ast.RangeExpr) Type { start := checker.checkExpression(module, currentScope, expression.Start) end := checker.checkExpression(module, currentScope, expression.End) diff --git a/src/internal/sema/statements.go b/src/internal/sema/statements.go index 63fbd65..2b5c501 100644 --- a/src/internal/sema/statements.go +++ b/src/internal/sema/statements.go @@ -98,6 +98,13 @@ func (checker *checker) checkAssignment( return } checker.checkVariableAccesses(module, currentScope, target) + if len(target.Accesses) > 0 { + if _, ok := target.Accesses[len(target.Accesses)-1].(*ast.EmptyIndexAccess); ok && + !valueType.IsUnknown() && valueType.Kind != TypeList { + checker.typeMismatch(module, statement.Value, + fmt.Sprintf("Type mismatch: cannot assign %s to %s[].", valueType.String(), variableName(target))) + } + } if target.Qualifier != nil { checker.checkImportedAssignment(module, target) diff --git a/src/internal/sema/symbols.go b/src/internal/sema/symbols.go index 38d29ce..e16ebfc 100644 --- a/src/internal/sema/symbols.go +++ b/src/internal/sema/symbols.go @@ -18,6 +18,7 @@ type VariableSymbol struct { Type Type Public bool Local bool + initialized bool } type SymbolTable struct { From f2c930e7305b7ec3aecbe748ec2baafc32471724 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 18:19:57 -0300 Subject: [PATCH 04/12] test(sema): cover semantic regressions --- src/internal/parser/top_level_test.go | 27 ++++ src/internal/sema/checker_integration_test.go | 13 ++ .../sema/collection_assignment_test.go | 71 +++++++++ .../sema/inference_definition_test.go | 142 ++++++++++++++++++ .../testdata/checker/public-local/puff.toml | 5 + .../checker/public-local/src/main.puff | 1 + 6 files changed, 259 insertions(+) create mode 100644 src/internal/sema/collection_assignment_test.go create mode 100644 src/internal/sema/inference_definition_test.go create mode 100644 src/internal/sema/testdata/checker/public-local/puff.toml create mode 100644 src/internal/sema/testdata/checker/public-local/src/main.puff diff --git a/src/internal/parser/top_level_test.go b/src/internal/parser/top_level_test.go index 2997dad..3324979 100644 --- a/src/internal/parser/top_level_test.go +++ b/src/internal/parser/top_level_test.go @@ -140,6 +140,33 @@ pub $tax = 0.1 } } +func TestParsePreservesPublicLocalForSemanticValidation(t *testing.T) { + input := "pub $_price = 50\n" + result := parseTestSource("public-local.puff", input) + + if len(result.Diagnostics) != 0 { + t.Fatalf("expected semantic validation to receive the declaration, got %#v", result.Diagnostics) + } + if len(result.File.Declarations) != 1 { + t.Fatalf("expected one declaration, got %#v", result.File.Declarations) + } + + declaration, ok := result.File.Declarations[0].(*ast.GlobalAssignment) + if !ok { + t.Fatalf("expected global assignment, got %T", result.File.Declarations[0]) + } + if !declaration.Public || declaration.Target == nil || !declaration.Target.Local || declaration.Target.Name.Name != "price" { + t.Fatalf("unexpected public local declaration: %#v", declaration) + } + value, ok := declaration.Value.(*ast.IntLiteral) + if !ok || value.Value != 50 { + t.Fatalf("unexpected public local value: %#v", declaration.Value) + } + if declaration.Span().StartOffset != 0 || declaration.Span().EndOffset != len(input)-1 { + t.Fatalf("unexpected declaration span: %#v", declaration.Span()) + } +} + func TestParseEventsAndBalancedBodies(t *testing.T) { result := parseTestSource("events.puff", ` fun nested diff --git a/src/internal/sema/checker_integration_test.go b/src/internal/sema/checker_integration_test.go index f707690..6cb8a93 100644 --- a/src/internal/sema/checker_integration_test.go +++ b/src/internal/sema/checker_integration_test.go @@ -119,6 +119,19 @@ func TestCheckIntegrationReportsDocumentedDiagnosticsWithoutCascades(t *testing. }, }, }, + { + name: "public local variable", + fixture: "public-local", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeInvalidPublicLocalVariable, + file: "main.puff", + line: 1, + message: "Local variables cannot be public.", + hint: "Only global variables can be exported.", + }, + }, + }, { name: "return and stop distinctions", fixture: "returns", diff --git a/src/internal/sema/collection_assignment_test.go b/src/internal/sema/collection_assignment_test.go new file mode 100644 index 0000000..dc7aa60 --- /dev/null +++ b/src/internal/sema/collection_assignment_test.go @@ -0,0 +1,71 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +func TestCheckCollectionAssignmentRequiresListValue(t *testing.T) { + t.Run("accepts list", func(t *testing.T) { + value := &ast.ListExpr{ + NodeBase: nttBase(3), + Elements: []ast.Expression{nttInt(1, 3)}, + } + + result := Check(nttProject(nttModule("main.puff", + nttEvent("load", collectionAssignment(value, 3)), + ))) + + nttAssertNoDiagnostics(t, result.Diagnostics) + }) + + t.Run("rejects non-list", func(t *testing.T) { + value := nttInt(1, 4) + + result := Check(nttProject(nttModule("main.puff", + nttEvent("load", collectionAssignment(value, 4)), + ))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot assign int to $players[].", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: value.Span(), + }) + }) + + t.Run("does not cascade for unknown value", func(t *testing.T) { + value := nttVariable("missing", false, 5) + + result := Check(nttProject(nttModule("main.puff", + nttEvent("load", collectionAssignment(value, 5)), + ))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: $missing", + Hint: "Declare it before using it: $missing = 0", + File: "main.puff", + Span: value.Span(), + }) + }) +} + +func collectionAssignment(value ast.Expression, line int) *ast.AssignmentStmt { + return &ast.AssignmentStmt{ + NodeBase: nttBase(line), + Target: &ast.VariableExpr{ + NodeBase: nttBase(line), + Name: nttIdentifier("players", line), + Accesses: []ast.VariableAccess{&ast.EmptyIndexAccess{NodeBase: nttBase(line)}}, + }, + Value: value, + } +} diff --git a/src/internal/sema/inference_definition_test.go b/src/internal/sema/inference_definition_test.go new file mode 100644 index 0000000..24ef61a --- /dev/null +++ b/src/internal/sema/inference_definition_test.go @@ -0,0 +1,142 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/token" +) + +func TestCheckReportsGlobalReadBeforeDefinitionWithoutCascade(t *testing.T) { + read := nttVariable("later", false, 1) + expression := &ast.BinaryExpr{ + NodeBase: nttBase(1), + Left: read, + Operator: token.Plus, + Right: nttString("value", 1), + } + module := nttModule("main.puff", + &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: nttVariable("copy", false, 1), + Value: expression, + }, + &ast.GlobalAssignment{ + NodeBase: nttBase(2), + Target: nttVariable("later", false, 2), + Value: nttInt(1, 2), + }, + ) + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: $later", + Hint: "Declare it before using it: $later = 0", + File: "main.puff", + Span: read.Span(), + }) + if typ := module.ExpressionTypes[expression]; !typ.IsUnknown() { + t.Fatalf("expected invalid initializer type to remain unknown, got %#v", typ) + } + if symbol := module.ResolvedVariables[read]; symbol != nil { + t.Fatalf("read before definition must not resolve, got %#v", symbol) + } +} + +func TestCheckAllowsFunctionForwardReferenceFromGlobalInitializer(t *testing.T) { + call := nttCall("calculate", true, 1) + module := nttModule("main.puff", + &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: nttVariable("result", false, 1), + Value: call, + }, + &ast.FunctionDecl{ + NodeBase: nttBase(2), + Name: nttIdentifier("calculate", 2), + ReturnType: nttType("int", 2), + Body: ast.Block{Statements: []ast.Statement{ + &ast.ReturnStmt{NodeBase: nttBase(3), Value: nttInt(42, 3)}, + }}, + }, + ) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if symbol := module.ResolvedCalls[call]; symbol == nil || symbol.Name != "calculate" { + t.Fatalf("expected forward function call to resolve, got %#v", symbol) + } + if typ := module.ExpressionTypes[call]; typ.Kind != TypeInt { + t.Fatalf("expected forward function call type int, got %#v", typ) + } +} + +func TestCheckInfersNumericListAndMapLiteralsIndependentlyOfOrder(t *testing.T) { + tests := []struct { + name string + expression ast.Expression + want Type + }{ + { + name: "list int then float", + expression: &ast.ListExpr{Elements: []ast.Expression{ + nttInt(1, 1), + nttFloat(2.5, 1), + }}, + want: Type{Kind: TypeList, Arguments: []Type{{Kind: TypeFloat}}}, + }, + { + name: "list float then int", + expression: &ast.ListExpr{Elements: []ast.Expression{ + nttFloat(2.5, 1), + nttInt(1, 1), + }}, + want: Type{Kind: TypeList, Arguments: []Type{{Kind: TypeFloat}}}, + }, + { + name: "map int then float", + expression: &ast.MapExpr{Entries: []ast.MapEntry{ + {Key: nttInt(1, 1), Value: nttFloat(1.5, 1)}, + {Key: nttFloat(2.5, 1), Value: nttInt(2, 1)}, + }}, + want: Type{Kind: TypeMap, Arguments: []Type{ + {Kind: TypeFloat}, + {Kind: TypeFloat}, + }}, + }, + { + name: "map float then int", + expression: &ast.MapExpr{Entries: []ast.MapEntry{ + {Key: nttFloat(2.5, 1), Value: nttInt(2, 1)}, + {Key: nttInt(1, 1), Value: nttFloat(1.5, 1)}, + }}, + want: Type{Kind: TypeMap, Arguments: []Type{ + {Kind: TypeFloat}, + {Kind: TypeFloat}, + }}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + module := nttModule("main.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: nttVariable("value", false, 1), + Value: test.expression, + }) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if got := module.ExpressionTypes[test.expression]; got.String() != test.want.String() { + t.Fatalf("expected inferred type %s, got %s", test.want.String(), got.String()) + } + }) + } +} diff --git a/src/internal/sema/testdata/checker/public-local/puff.toml b/src/internal/sema/testdata/checker/public-local/puff.toml new file mode 100644 index 0000000..d437c9a --- /dev/null +++ b/src/internal/sema/testdata/checker/public-local/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-public-local" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/public-local/src/main.puff b/src/internal/sema/testdata/checker/public-local/src/main.puff new file mode 100644 index 0000000..cf36852 --- /dev/null +++ b/src/internal/sema/testdata/checker/public-local/src/main.puff @@ -0,0 +1 @@ +pub $_price = 50 From fc34deac8139f1e13275c05fd04f0456a235cc7d Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 18:41:35 -0300 Subject: [PATCH 05/12] fix(sema): harden scopes and global paths --- src/internal/sema/checker.go | 70 +++++++++++++++++- src/internal/sema/expressions.go | 22 ++++-- src/internal/sema/statements.go | 122 +++++++++++++++++++++++++++---- src/internal/sema/symbols.go | 39 ++++++++++ src/internal/sema/types.go | 10 ++- 5 files changed, 235 insertions(+), 28 deletions(-) diff --git a/src/internal/sema/checker.go b/src/internal/sema/checker.go index 96a595c..85404fb 100644 --- a/src/internal/sema/checker.go +++ b/src/internal/sema/checker.go @@ -2,6 +2,7 @@ package sema import ( "fmt" + "sort" "strings" "github.com/puff-lang/puff/internal/ast" @@ -99,12 +100,14 @@ func (checker *checker) indexGlobal(module *Module, declaration *ast.GlobalAssig return } - module.Symbols.Globals[target.Name.Name] = &VariableSymbol{ + path, depth := globalPath(target) + module.Symbols.Globals[path] = &VariableSymbol{ Name: target.Name.Name, Declaration: declaration, Module: module, Type: Type{Kind: TypeUnknown}, Public: declaration.Public, + AccessDepth: depth, } } @@ -136,8 +139,8 @@ func (checker *checker) checkModules() { continue } checker.checkRequiredEvents(module) - checker.checkGlobalInitializers(module) } + checker.checkGlobalInitializersInDependencyOrder() for _, module := range checker.project.Modules { if module == nil || module.Syntax == nil { @@ -154,6 +157,54 @@ func (checker *checker) checkModules() { } } +func (checker *checker) checkGlobalInitializersInDependencyOrder() { + state := make(map[*Module]uint8) + modules := append([]*Module(nil), checker.project.Modules...) + sort.SliceStable(modules, func(left, right int) bool { + if modules[left] == nil { + return false + } + if modules[right] == nil { + return true + } + return modules[left].Source.RelPath < modules[right].Source.RelPath + }) + + var checkModule func(*Module) + checkModule = func(module *Module) { + if module == nil || module.Syntax == nil || state[module] == 2 { + return + } + if state[module] == 1 { + return + } + state[module] = 1 + + dependencies := make([]*Module, 0, len(module.Imports)) + seen := make(map[*Module]bool) + for _, imported := range module.Imports { + if imported == nil || imported.Target == nil || seen[imported.Target] { + continue + } + seen[imported.Target] = true + dependencies = append(dependencies, imported.Target) + } + sort.Slice(dependencies, func(left, right int) bool { + return dependencies[left].Source.RelPath < dependencies[right].Source.RelPath + }) + for _, dependency := range dependencies { + checkModule(dependency) + } + + checker.checkGlobalInitializers(module) + state[module] = 2 + } + + for _, module := range modules { + checkModule(module) + } +} + func (checker *checker) checkGlobalInitializers(module *Module) { for _, declaration := range module.Syntax.Declarations { global, ok := declaration.(*ast.GlobalAssignment) @@ -165,7 +216,12 @@ func (checker *checker) checkGlobalInitializers(module *Module) { if global.Target == nil || global.Target.Local { continue } - if symbol, ok := module.Symbols.Globals[global.Target.Name.Name]; ok { + if endsWithEmptyIndex(global.Target) && !typ.IsUnknown() && typ.Kind != TypeList { + checker.typeMismatch(module, global.Value, + fmt.Sprintf("Type mismatch: cannot assign %s to %s[].", typ.String(), variableName(global.Target))) + } + path, _ := globalPath(global.Target) + if symbol, ok := module.Symbols.Globals[path]; ok { symbol.Type = typ symbol.initialized = true module.ResolvedVariables[global.Target] = symbol @@ -174,6 +230,14 @@ func (checker *checker) checkGlobalInitializers(module *Module) { } } +func endsWithEmptyIndex(variable *ast.VariableExpr) bool { + if variable == nil || len(variable.Accesses) == 0 { + return false + } + _, ok := variable.Accesses[len(variable.Accesses)-1].(*ast.EmptyIndexAccess) + return ok +} + func (checker *checker) checkFunction(module *Module, declaration *ast.FunctionDecl) { if declaration == nil { return diff --git a/src/internal/sema/expressions.go b/src/internal/sema/expressions.go index 7b18633..4e3eec9 100644 --- a/src/internal/sema/expressions.go +++ b/src/internal/sema/expressions.go @@ -272,7 +272,7 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia if variable.Qualifier != nil { imported, ok := module.Import(variable.Qualifier.Name) if ok && imported != nil && imported.Target != nil && imported.Target.Symbols != nil { - symbol = imported.Target.Symbols.Globals[variable.Name.Name] + symbol = imported.Target.Symbols.lookupGlobal(variable) if symbol != nil && !symbol.Public { symbol = nil } @@ -282,7 +282,7 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia } else if typ, ok := currentScope.lookupName(variable.Name.Name); ok { return checker.typeAfterAccesses(typ, variable.Accesses) } else if module != nil && module.Symbols != nil { - symbol = module.Symbols.Globals[variable.Name.Name] + symbol = module.Symbols.lookupGlobal(variable) } if symbol == nil { @@ -300,7 +300,7 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia } module.ResolvedVariables[variable] = symbol - return checker.typeAfterAccesses(symbol.Type, variable.Accesses) + return checker.typeAfterAccesses(symbol.Type, variable.Accesses[symbol.AccessDepth:]) } func (checker *checker) checkVariableAccesses(module *Module, currentScope *scope, variable *ast.VariableExpr) { @@ -346,6 +346,13 @@ func variableName(variable *ast.VariableExpr) string { if variable.Qualifier != nil { name = variable.Qualifier.Name + "." + name } + for _, access := range variable.Accesses { + field, ok := access.(*ast.FieldAccess) + if !ok { + break + } + name += "." + field.Field.Name + } return name } @@ -380,14 +387,17 @@ func (checker *checker) checkMap(module *Module, currentScope *scope, expression } func mergeInferredTypes(left Type, right Type) Type { - if left.IsUnknown() || right.IsUnknown() { + if left.IsUnknown() && !left.incompatible || right.IsUnknown() && !right.incompatible { return Type{Kind: TypeUnknown} } + if left.incompatible || right.incompatible { + return Type{Kind: TypeUnknown, incompatible: true} + } if numeric := numericType(left, right); !numeric.IsUnknown() { return numeric } if left.Kind != right.Kind || left.Kind == TypeNamed && left.Name != right.Name { - return Type{Kind: TypeUnknown} + return Type{Kind: TypeUnknown, incompatible: true} } if len(left.Arguments) == 0 { return right @@ -396,7 +406,7 @@ func mergeInferredTypes(left Type, right Type) Type { return left } if len(left.Arguments) != len(right.Arguments) { - return Type{Kind: TypeUnknown} + return Type{Kind: TypeUnknown, incompatible: true} } merged := left diff --git a/src/internal/sema/statements.go b/src/internal/sema/statements.go index 2b5c501..41e6334 100644 --- a/src/internal/sema/statements.go +++ b/src/internal/sema/statements.go @@ -12,6 +12,59 @@ type flowContext struct { returnType Type } +func isolatedFlowScope(parent *scope) *scope { + current := &scope{ + parent: parent, + names: make(map[string]Type), + locals: copyLocals(parent), + } + current.owner = current + return current +} + +func copyLocals(current *scope) map[string]*VariableSymbol { + copied := make(map[string]*VariableSymbol) + if current == nil { + return copied + } + owner := current.owner + if owner == nil { + owner = current + } + for name, symbol := range owner.locals { + copied[name] = symbol + } + return copied +} + +func mergeFlowScopes(target *scope, paths []*scope) { + if target == nil || len(paths) == 0 { + return + } + + merged := copyLocals(paths[0]) + for name, first := range merged { + combined := *first + for _, path := range paths[1:] { + candidate, ok := copyLocals(path)[name] + if !ok { + delete(merged, name) + break + } + combined.Type = mergeInferredTypes(combined.Type, candidate.Type) + } + if _, ok := merged[name]; ok { + merged[name] = &combined + } + } + + owner := target.owner + if owner == nil { + owner = target + } + owner.locals = merged +} + func (checker *checker) checkBlock( module *Module, currentScope *scope, @@ -128,16 +181,19 @@ func (checker *checker) checkAssignment( return } - symbol := module.Symbols.Globals[target.Name.Name] + path, depth := globalPath(target) + symbol := module.Symbols.lookupGlobal(target) if symbol == nil { symbol = &VariableSymbol{ Name: target.Name.Name, Declaration: statement, Module: module, + AccessDepth: depth, } - module.Symbols.Globals[target.Name.Name] = symbol + module.Symbols.Globals[path] = symbol } - if len(target.Accesses) == 0 { + if symbol.AccessDepth == len(target.Accesses) || + endsWithEmptyIndex(target) && symbol.AccessDepth == len(target.Accesses)-1 { symbol.Type = valueType } module.ResolvedVariables[target] = symbol @@ -150,7 +206,7 @@ func (checker *checker) checkImportedAssignment(module *Module, target *ast.Vari return } - symbol := imported.Target.Symbols.Globals[target.Name.Name] + symbol := imported.Target.Symbols.lookupGlobal(target) if symbol == nil || !symbol.Public { checker.undefinedVariable(module, target) return @@ -172,13 +228,37 @@ func (checker *checker) checkAdd(module *Module, currentScope *scope, statement if statement == nil { return } - checker.checkExpression(module, currentScope, statement.Value) + valueType := checker.checkExpression(module, currentScope, statement.Value) if target, ok := statement.Target.(*ast.VariableExpr); ok { - checker.checkVariable(module, currentScope, target) + targetType := checker.checkVariable(module, currentScope, target) + if len(target.Accesses) > 0 { + if _, empty := target.Accesses[len(target.Accesses)-1].(*ast.EmptyIndexAccess); empty { + if targetType.Kind != TypeList || len(targetType.Arguments) == 0 { + return + } + targetType = targetType.Arguments[0] + } + } + if !addCompatible(targetType, valueType) { + checker.typeMismatch(module, statement.Value, + fmt.Sprintf("Type mismatch: cannot add %s to %s.", valueType.String(), targetType.String())) + } } // AccessExpr is deliberately deferred to T12. } +func addCompatible(target Type, value Type) bool { + if target.IsUnknown() || value.IsUnknown() { + return true + } + targetNumeric := target.Kind == TypeInt || target.Kind == TypeFloat + valueNumeric := value.Kind == TypeInt || value.Kind == TypeFloat + if targetNumeric && valueNumeric { + return true + } + return compatible(target, value) +} + func (checker *checker) checkIf( module *Module, currentScope *scope, @@ -190,22 +270,32 @@ func (checker *checker) checkIf( } checker.requireBool(module, statement.Condition, checker.checkExpression(module, currentScope, statement.Condition)) - fallsThrough := checker.checkBlock(module, currentScope, statement.Then, context) + + fallthroughPaths := make([]*scope, 0, len(statement.ElseIf)+2) + thenScope := isolatedFlowScope(currentScope) + if checker.checkBlock(module, thenScope, statement.Then, context) { + fallthroughPaths = append(fallthroughPaths, thenScope) + } for _, clause := range statement.ElseIf { checker.requireBool(module, clause.Condition, checker.checkExpression(module, currentScope, clause.Condition)) - if checker.checkBlock(module, currentScope, clause.Body, context) { - fallsThrough = true + clauseScope := isolatedFlowScope(currentScope) + if checker.checkBlock(module, clauseScope, clause.Body, context) { + fallthroughPaths = append(fallthroughPaths, clauseScope) } } if statement.Else == nil { + fallthroughPaths = append(fallthroughPaths, isolatedFlowScope(currentScope)) + mergeFlowScopes(currentScope, fallthroughPaths) return true } - if checker.checkBlock(module, currentScope, *statement.Else, context) { - fallsThrough = true + elseScope := isolatedFlowScope(currentScope) + if checker.checkBlock(module, elseScope, *statement.Else, context) { + fallthroughPaths = append(fallthroughPaths, elseScope) } - return fallsThrough + mergeFlowScopes(currentScope, fallthroughPaths) + return len(fallthroughPaths) > 0 } func (checker *checker) checkLoopTimes( @@ -216,7 +306,7 @@ func (checker *checker) checkLoopTimes( ) { count := checker.checkExpression(module, currentScope, statement.Count) checker.requireNumeric(module, statement.Count, count) - loopScope := newInjectedScope(currentScope) + loopScope := newInjectedScope(isolatedFlowScope(currentScope)) loopScope.defineName("loop.index", Type{Kind: TypeInt}) checker.checkBlock(module, loopScope, statement.Body, context) } @@ -233,7 +323,7 @@ func (checker *checker) checkLoopRange( checker.requireNumeric(module, statement.End, end) valueType := numericType(start, end) - loopScope := newInjectedScope(currentScope) + loopScope := newInjectedScope(isolatedFlowScope(currentScope)) loopScope.defineName("loop.index", Type{Kind: TypeInt}) loopScope.defineName("loop.value", valueType) checker.checkBlock(module, loopScope, statement.Body, context) @@ -245,7 +335,7 @@ func (checker *checker) checkLoopPlayers( statement *ast.LoopPlayersStmt, context flowContext, ) { - loopScope := newInjectedScope(currentScope) + loopScope := newInjectedScope(isolatedFlowScope(currentScope)) loopScope.defineName("loop.index", Type{Kind: TypeInt}) loopScope.defineName("loop.player", Type{Kind: TypeNamed, Name: "Player"}) checker.checkBlock(module, loopScope, statement.Body, context) @@ -261,7 +351,7 @@ func (checker *checker) checkLoopEntities( checker.requireNumeric(module, statement.Radius, radius) checker.checkExpression(module, currentScope, statement.Around) - loopScope := newInjectedScope(currentScope) + loopScope := newInjectedScope(isolatedFlowScope(currentScope)) loopScope.defineName("loop.index", Type{Kind: TypeInt}) loopScope.defineName("loop.entity", Type{Kind: TypeNamed, Name: "Entity"}) checker.checkBlock(module, loopScope, statement.Body, context) diff --git a/src/internal/sema/symbols.go b/src/internal/sema/symbols.go index e16ebfc..36bc811 100644 --- a/src/internal/sema/symbols.go +++ b/src/internal/sema/symbols.go @@ -18,6 +18,7 @@ type VariableSymbol struct { Type Type Public bool Local bool + AccessDepth int initialized bool } @@ -33,6 +34,44 @@ func newSymbolTable() *SymbolTable { } } +func globalPath(variable *ast.VariableExpr) (string, int) { + if variable == nil || variable.Name.Name == "" { + return "", 0 + } + + path := variable.Name.Name + depth := 0 + for _, access := range variable.Accesses { + field, ok := access.(*ast.FieldAccess) + if !ok { + break + } + path += "." + field.Field.Name + depth++ + } + return path, depth +} + +func (symbols *SymbolTable) lookupGlobal(variable *ast.VariableExpr) *VariableSymbol { + if symbols == nil || variable == nil { + return nil + } + + path := variable.Name.Name + symbol := symbols.Globals[path] + for _, access := range variable.Accesses { + field, ok := access.(*ast.FieldAccess) + if !ok { + break + } + path += "." + field.Field.Name + if candidate := symbols.Globals[path]; candidate != nil { + symbol = candidate + } + } + return symbol +} + type scope struct { parent *scope owner *scope diff --git a/src/internal/sema/types.go b/src/internal/sema/types.go index 6ffd389..3ee49ff 100644 --- a/src/internal/sema/types.go +++ b/src/internal/sema/types.go @@ -21,9 +21,10 @@ const ( ) type Type struct { - Kind TypeKind - Name string - Arguments []Type + Kind TypeKind + Name string + Arguments []Type + incompatible bool } func (typ Type) String() string { @@ -81,6 +82,9 @@ var builtInTypes = map[string]TypeKind{ } func compatible(expected Type, actual Type) bool { + if expected.incompatible || actual.incompatible { + return false + } if expected.IsUnknown() || actual.IsUnknown() { return true } From c565ad9e4a375f5b48cc2f3c9813f891c0408d56 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 18:41:37 -0300 Subject: [PATCH 06/12] test(sema): cover scope and global path regressions --- src/internal/sema/checker_integration_test.go | 48 ++++ src/internal/sema/flow_add_regression_test.go | 160 +++++++++++ .../sema/global_type_regression_test.go | 268 ++++++++++++++++++ .../checker/semantic-regressions/puff.toml | 5 + .../src/a_import_order.puff | 5 + .../src/b_visibility.puff | 4 + .../src/c_top_level_collection.puff | 1 + .../src/d_heterogeneous_collection.puff | 3 + .../src/e_branch_local.puff | 7 + .../semantic-regressions/src/f_add_type.puff | 4 + .../semantic-regressions/src/z_typed.puff | 1 + .../src/z_visibility.puff | 2 + 12 files changed, 508 insertions(+) create mode 100644 src/internal/sema/flow_add_regression_test.go create mode 100644 src/internal/sema/global_type_regression_test.go create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/puff.toml create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/a_import_order.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/b_visibility.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/c_top_level_collection.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/d_heterogeneous_collection.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/e_branch_local.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/f_add_type.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/z_typed.puff create mode 100644 src/internal/sema/testdata/checker/semantic-regressions/src/z_visibility.puff diff --git a/src/internal/sema/checker_integration_test.go b/src/internal/sema/checker_integration_test.go index 6cb8a93..bf66661 100644 --- a/src/internal/sema/checker_integration_test.go +++ b/src/internal/sema/checker_integration_test.go @@ -166,6 +166,54 @@ func TestCheckIntegrationReportsDocumentedDiagnosticsWithoutCascades(t *testing. }, }, }, + { + name: "semantic regressions", + fixture: "semantic-regressions", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeUndefinedVariable, + file: "b_visibility.puff", + line: 4, + message: "Undefined variable: visibility.$config.secret", + hint: "Declare it before using it: visibility.$config.secret = 0", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "c_top_level_collection.puff", + line: 1, + message: "Type mismatch: cannot assign int to $players[].", + hint: "Convert one value or use compatible types.", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "a_import_order.puff", + line: 4, + message: "Type mismatch: cannot return int as string.", + hint: "Return a value compatible with string.", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "d_heterogeneous_collection.puff", + line: 2, + message: "Type mismatch: cannot return list as list.", + hint: "Return a value compatible with list.", + }, + { + code: diagnostic.CodeUndefinedVariable, + file: "e_branch_local.puff", + line: 5, + message: "Undefined variable: $_value", + hint: "Declare it before using it: $_value = 0", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "f_add_type.puff", + line: 3, + message: "Type mismatch: cannot add string to int.", + hint: "Convert one value or use compatible types.", + }, + }, + }, } for _, test := range tests { diff --git a/src/internal/sema/flow_add_regression_test.go b/src/internal/sema/flow_add_regression_test.go new file mode 100644 index 0000000..9be0197 --- /dev/null +++ b/src/internal/sema/flow_add_regression_test.go @@ -0,0 +1,160 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +func TestCheckIfUsesIndependentLocalSnapshots(t *testing.T) { + leakedRead := nttVariable("value", true, 6) + statement := &ast.IfStmt{ + NodeBase: nttBase(3), + Condition: &ast.BoolLiteral{NodeBase: nttBase(3), Value: true}, + Then: ast.Block{Statements: []ast.Statement{ + localAssignment("value", nttInt(1, 4), 4), + }}, + Else: &ast.Block{Statements: []ast.Statement{ + nttExprStmt(leakedRead, 6), + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", statement)))) + + nttAssertDiagnostic(t, result.Diagnostics, undefinedLocalDiagnostic(leakedRead)) +} + +func TestCheckIfKeepsOnlyDefinitelyDefinedLocals(t *testing.T) { + priorRead := nttVariable("prior", true, 10) + sharedRead := nttVariable("shared", true, 11) + oneBranchRead := nttVariable("oneBranch", true, 12) + statement := &ast.IfStmt{ + NodeBase: nttBase(4), + Condition: &ast.BoolLiteral{NodeBase: nttBase(4), Value: true}, + Then: ast.Block{Statements: []ast.Statement{ + localAssignment("shared", nttInt(1, 5), 5), + localAssignment("oneBranch", nttInt(1, 6), 6), + }}, + Else: &ast.Block{Statements: []ast.Statement{ + localAssignment("shared", nttFloat(2, 8), 8), + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("prior", nttInt(1, 2), 2), + statement, + nttExprStmt(priorRead, 10), + nttExprStmt(sharedRead, 11), + nttExprStmt(oneBranchRead, 12), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, undefinedLocalDiagnostic(oneBranchRead)) + if typ := result.Project.Modules[0].ExpressionTypes[priorRead]; typ.Kind != TypeInt { + t.Fatalf("expected prior local to remain int, got %s", typ.String()) + } + if typ := result.Project.Modules[0].ExpressionTypes[sharedRead]; typ.Kind != TypeFloat { + t.Fatalf("expected numeric branch types to merge to float, got %s", typ.String()) + } +} + +func TestCheckLoopDoesNotDefineLocalAfterBody(t *testing.T) { + loopLocalRead := nttVariable("inside", true, 6) + loop := &ast.LoopTimesStmt{ + NodeBase: nttBase(3), + Count: nttInt(1, 3), + Body: ast.Block{Statements: []ast.Statement{ + localAssignment("inside", nttInt(1, 4), 4), + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + loop, + nttExprStmt(loopLocalRead, 6), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, undefinedLocalDiagnostic(loopLocalRead)) +} + +func TestCheckAddValidatesTargetCompatibility(t *testing.T) { + t.Run("accepts numeric scalar promotion", func(t *testing.T) { + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("amount", nttInt(1, 2), 2), + addStatement(nttFloat(2.5, 3), nttVariable("amount", true, 3), 3), + )))) + + nttAssertNoDiagnostics(t, result.Diagnostics) + }) + + t.Run("rejects incompatible scalar without cascade", func(t *testing.T) { + value := nttString("wrong", 3) + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("amount", nttInt(1, 2), 2), + addStatement(value, nttVariable("amount", true, 3), 3), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot add string to int.", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: value.Span(), + }) + }) + + t.Run("checks known list element type", func(t *testing.T) { + value := nttString("wrong", 4) + target := nttVariable("values", true, 4) + target.Accesses = []ast.VariableAccess{&ast.EmptyIndexAccess{NodeBase: nttBase(4)}} + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("values", &ast.ListExpr{ + NodeBase: nttBase(2), + Elements: []ast.Expression{nttInt(1, 2)}, + }, 2), + addStatement(nttInt(2, 3), listTarget("values", 3), 3), + addStatement(value, target, 4), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot add string to int.", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: value.Span(), + }) + }) +} + +func localAssignment(name string, value ast.Expression, line int) *ast.AssignmentStmt { + return &ast.AssignmentStmt{ + NodeBase: nttBase(line), + Target: nttVariable(name, true, line), + Value: value, + } +} + +func addStatement(value ast.Expression, target ast.Assignable, line int) *ast.AddStmt { + return &ast.AddStmt{NodeBase: nttBase(line), Value: value, Target: target} +} + +func listTarget(name string, line int) *ast.VariableExpr { + target := nttVariable(name, true, line) + target.Accesses = []ast.VariableAccess{&ast.EmptyIndexAccess{NodeBase: nttBase(line)}} + return target +} + +func undefinedLocalDiagnostic(variable *ast.VariableExpr) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: $_" + variable.Name.Name, + Hint: "Declare it before using it: $_" + variable.Name.Name + " = 0", + File: "main.puff", + Span: variable.Span(), + } +} diff --git a/src/internal/sema/global_type_regression_test.go b/src/internal/sema/global_type_regression_test.go new file mode 100644 index 0000000..d346a86 --- /dev/null +++ b/src/internal/sema/global_type_regression_test.go @@ -0,0 +1,268 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +func TestCheckTopLevelCollectionAssignmentRequiresListValue(t *testing.T) { + value := nttInt(1, 1) + target := nttVariableWithFields("players", 1) + target.Accesses = append(target.Accesses, &ast.EmptyIndexAccess{NodeBase: nttBase(1)}) + module := nttModule("main.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: target, + Value: value, + }) + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot assign int to $players[].", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: value.Span(), + }) +} + +func TestCheckGlobalStaticPathVisibilityIsOrderIndependent(t *testing.T) { + for _, publicFirst := range []bool{true, false} { + name := "private path first" + if publicFirst { + name = "public path first" + } + t.Run(name, func(t *testing.T) { + public := globalPathDeclaration("config", "name", true, nttString("Shop", 1), 1) + private := globalPathDeclaration("config", "secret", false, nttInt(7, 2), 2) + declarations := []ast.Declaration{private, public} + if publicFirst { + declarations = []ast.Declaration{public, private} + } + declarations = append(declarations, &ast.GlobalAssignment{ + NodeBase: nttBase(3), + Target: nttVariable("coins", false, 3), + Value: nttInt(10, 3), + }) + library := nttModule("lib/config.puff", declarations...) + + publicRead := importedVariableWithFields("config", "config", 4, "name") + privateRead := importedVariableWithFields("config", "config", 5, "secret") + main := nttModule("main.puff", nttEvent("load", + nttExprStmt(publicRead, 4), + nttExprStmt(privateRead, 5), + )) + main.Imports["config"] = &Import{Prefix: "config", Target: library} + + result := Check(nttProject(main, library)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: config.$config.secret", + Hint: "Declare it before using it: config.$config.secret = 0", + File: "main.puff", + Span: privateRead.Span(), + }) + if typ := main.ExpressionTypes[publicRead]; typ.Kind != TypeString { + t.Fatalf("expected public path type string, got %s", typ.String()) + } + if library.Symbols.Globals["config.name"] == nil || !library.Symbols.Globals["config.name"].Public { + t.Fatalf("expected public path symbol, got %#v", library.Symbols.Globals) + } + if library.Symbols.Globals["config.secret"] == nil || library.Symbols.Globals["config.secret"].Public { + t.Fatalf("expected private path symbol, got %#v", library.Symbols.Globals) + } + if library.Symbols.Globals["coins"] == nil { + t.Fatalf("expected simple root symbol to remain under Globals[\"coins\"]") + } + }) + } + + t.Run("public imported path is read only", func(t *testing.T) { + library := nttModule( + "lib/config.puff", + globalPathDeclaration("config", "name", true, nttString("Shop", 1), 1), + ) + target := importedVariableWithFields("config", "config", 3, "name") + main := nttModule("main.puff", nttEvent("load", + &ast.AssignmentStmt{NodeBase: nttBase(3), Target: target, Value: nttString("Other", 3)}, + )) + main.Imports["config"] = &Import{Prefix: "config", Target: library} + + result := Check(nttProject(main, library)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeAssignToImportedPublicVar, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Cannot assign to imported public variable: config.$config.name", + Hint: "Use a public function like config.setTax(0.2).", + File: "main.puff", + Span: target.Span(), + }) + }) +} + +func TestCheckHeterogeneousCollectionsDoNotMatchGenericTypes(t *testing.T) { + tests := []struct { + name string + returnType *ast.TypeRef + value ast.Expression + }{ + { + name: "list", + returnType: nttGenericType("list", 1, nttType("int", 1)), + value: &ast.ListExpr{NodeBase: nttBase(2), Elements: []ast.Expression{ + nttInt(1, 2), + nttString("wrong", 2), + }}, + }, + { + name: "map", + returnType: nttGenericType("map", 1, nttType("string", 1), nttType("int", 1)), + value: &ast.MapExpr{NodeBase: nttBase(2), Entries: []ast.MapEntry{ + {Key: nttString("right", 2), Value: nttInt(1, 2)}, + {Key: nttString("wrong", 2), Value: nttString("wrong", 2)}, + }}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + function := returningFunction("values", test.returnType, test.value) + result := Check(nttProject(nttModule("main.puff", function))) + + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != diagnostic.CodeTypeMismatch { + t.Fatalf("expected one TYPE_MISMATCH, got %#v", result.Diagnostics) + } + }) + } + + t.Run("unknown from prior error suppresses return cascade", func(t *testing.T) { + missing := nttVariable("missing", false, 2) + value := &ast.ListExpr{NodeBase: nttBase(2), Elements: []ast.Expression{ + nttInt(1, 2), + nttString("incompatible", 2), + missing, + }} + function := returningFunction("values", nttGenericType("list", 1, nttType("int", 1)), value) + + result := Check(nttProject(nttModule("main.puff", function))) + + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != diagnostic.CodeUndefinedVariable { + t.Fatalf("expected only UNDEFINED_VARIABLE, got %#v", result.Diagnostics) + } + }) +} + +func TestCheckImportedGlobalInitializerTypeIsModuleOrderIndependent(t *testing.T) { + for _, importerFirst := range []bool{true, false} { + name := "dependency first" + if importerFirst { + name = "importer first" + } + t.Run(name, func(t *testing.T) { + call := nttCall("calculate", true, 1) + library := nttModule("lib/config.puff", + globalPathDeclaration("config", "value", true, call, 1), + &ast.FunctionDecl{ + NodeBase: nttBase(2), + Name: nttIdentifier("calculate", 2), + ReturnType: nttType("int", 2), + Body: ast.Block{Statements: []ast.Statement{ + &ast.ReturnStmt{NodeBase: nttBase(3), Value: nttInt(42, 3)}, + }}, + }, + ) + read := importedVariableWithFields("config", "config", 1, "value") + main := nttModule("main.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: nttVariable("copy", false, 1), + Value: read, + }) + main.Imports["config"] = &Import{Prefix: "config", Target: library} + + modules := []*Module{library, main} + if importerFirst { + modules = []*Module{main, library} + } + result := Check(nttProject(modules...)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if got := main.Symbols.Globals["copy"].Type.Kind; got != TypeInt { + t.Fatalf("expected imported initializer type int, got %s", got) + } + if library.ResolvedCalls[call] == nil { + t.Fatal("expected forward function reference to resolve") + } + }) + } + + t.Run("local global read before definition remains invalid", func(t *testing.T) { + read := nttVariable("later", false, 1) + module := nttModule("main.puff", + &ast.GlobalAssignment{NodeBase: nttBase(1), Target: nttVariable("copy", false, 1), Value: read}, + &ast.GlobalAssignment{NodeBase: nttBase(2), Target: nttVariable("later", false, 2), Value: nttInt(1, 2)}, + ) + + result := Check(nttProject(module)) + + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != diagnostic.CodeUndefinedVariable { + t.Fatalf("expected one UNDEFINED_VARIABLE, got %#v", result.Diagnostics) + } + }) +} + +func globalPathDeclaration( + root string, + field string, + public bool, + value ast.Expression, + line int, +) *ast.GlobalAssignment { + return &ast.GlobalAssignment{ + NodeBase: nttBase(line), + Public: public, + Target: nttVariableWithFields(root, line, field), + Value: value, + } +} + +func nttVariableWithFields(root string, line int, fields ...string) *ast.VariableExpr { + variable := nttVariable(root, false, line) + for _, field := range fields { + variable.Accesses = append(variable.Accesses, &ast.FieldAccess{ + NodeBase: nttBase(line), + Field: nttIdentifier(field, line), + }) + } + return variable +} + +func importedVariableWithFields(prefix string, root string, line int, fields ...string) *ast.VariableExpr { + variable := nttImportedVariable(prefix, root, line) + for _, field := range fields { + variable.Accesses = append(variable.Accesses, &ast.FieldAccess{ + NodeBase: nttBase(line), + Field: nttIdentifier(field, line), + }) + } + return variable +} + +func returningFunction(name string, returnType *ast.TypeRef, value ast.Expression) *ast.FunctionDecl { + return &ast.FunctionDecl{ + NodeBase: nttBase(1), + Name: nttIdentifier(name, 1), + ReturnType: returnType, + Body: ast.Block{Statements: []ast.Statement{ + &ast.ReturnStmt{NodeBase: nttBase(2), Value: value}, + }}, + } +} diff --git a/src/internal/sema/testdata/checker/semantic-regressions/puff.toml b/src/internal/sema/testdata/checker/semantic-regressions/puff.toml new file mode 100644 index 0000000..44b3d19 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-semantic-regressions" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/a_import_order.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/a_import_order.puff new file mode 100644 index 0000000..c1ed710 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/a_import_order.puff @@ -0,0 +1,5 @@ +require "z_typed" + +fun importedTypeMismatch -> string + return z_typed.$count +end diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/b_visibility.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/b_visibility.puff new file mode 100644 index 0000000..3b6b635 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/b_visibility.puff @@ -0,0 +1,4 @@ +require "z_visibility" as visibility + +$public_name = visibility.$config.name +$private_secret = visibility.$config.secret diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/c_top_level_collection.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/c_top_level_collection.puff new file mode 100644 index 0000000..a4070d1 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/c_top_level_collection.puff @@ -0,0 +1 @@ +$players[] = 1 diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/d_heterogeneous_collection.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/d_heterogeneous_collection.puff new file mode 100644 index 0000000..1366708 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/d_heterogeneous_collection.puff @@ -0,0 +1,3 @@ +fun heterogeneous -> list + return [1, "wrong"] +end diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/e_branch_local.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/e_branch_local.puff new file mode 100644 index 0000000..13ce44b --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/e_branch_local.puff @@ -0,0 +1,7 @@ +fun branchLocal(flag: bool) + if flag + $_value = 1 + else + $_copy = $_value + end +end diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/f_add_type.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/f_add_type.puff new file mode 100644 index 0000000..3d89024 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/f_add_type.puff @@ -0,0 +1,4 @@ +$coins = 1 +on load + add "wrong" to $coins +end diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/z_typed.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/z_typed.puff new file mode 100644 index 0000000..050ddfd --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/z_typed.puff @@ -0,0 +1 @@ +pub $count = 1 diff --git a/src/internal/sema/testdata/checker/semantic-regressions/src/z_visibility.puff b/src/internal/sema/testdata/checker/semantic-regressions/src/z_visibility.puff new file mode 100644 index 0000000..520d3b2 --- /dev/null +++ b/src/internal/sema/testdata/checker/semantic-regressions/src/z_visibility.puff @@ -0,0 +1,2 @@ +pub $config.name = "Shop" +$config.secret = "hidden" From ee61ae2b9a5ab5afab08dd24145ae41b6f46b716 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 19:09:14 -0300 Subject: [PATCH 07/12] fix(sema): stabilize cyclic globals and range flow --- src/internal/sema/checker.go | 90 +++++++++++++++++++++++---------- src/internal/sema/statements.go | 29 +++++++++-- src/internal/sema/symbols.go | 55 ++++++++++++++++++-- 3 files changed, 137 insertions(+), 37 deletions(-) diff --git a/src/internal/sema/checker.go b/src/internal/sema/checker.go index 85404fb..ac89d00 100644 --- a/src/internal/sema/checker.go +++ b/src/internal/sema/checker.go @@ -10,8 +10,9 @@ import ( ) type checker struct { - project *Project - diagnostics []diagnostic.Diagnostic + project *Project + diagnostics []diagnostic.Diagnostic + suppressDiagnostics bool } func Check(project *Project) Result { @@ -158,7 +159,6 @@ func (checker *checker) checkModules() { } func (checker *checker) checkGlobalInitializersInDependencyOrder() { - state := make(map[*Module]uint8) modules := append([]*Module(nil), checker.project.Modules...) sort.SliceStable(modules, func(left, right int) bool { if modules[left] == nil { @@ -170,42 +170,54 @@ func (checker *checker) checkGlobalInitializersInDependencyOrder() { return modules[left].Source.RelPath < modules[right].Source.RelPath }) - var checkModule func(*Module) - checkModule = func(module *Module) { - if module == nil || module.Syntax == nil || state[module] == 2 { - return + globalCount := 0 + for _, module := range modules { + if module == nil || module.Syntax == nil { + continue } - if state[module] == 1 { - return + for _, declaration := range module.Syntax.Declarations { + if _, ok := declaration.(*ast.GlobalAssignment); ok { + globalCount++ + } } - state[module] = 1 + } - dependencies := make([]*Module, 0, len(module.Imports)) - seen := make(map[*Module]bool) - for _, imported := range module.Imports { - if imported == nil || imported.Target == nil || seen[imported.Target] { - continue - } - seen[imported.Target] = true - dependencies = append(dependencies, imported.Target) + checker.suppressDiagnostics = true + for iteration := 0; iteration <= globalCount; iteration++ { + checker.resetGlobalInitialization(modules) + changed := false + for _, module := range modules { + changed = checker.checkGlobalInitializers(module, true) || changed } - sort.Slice(dependencies, func(left, right int) bool { - return dependencies[left].Source.RelPath < dependencies[right].Source.RelPath - }) - for _, dependency := range dependencies { - checkModule(dependency) + if !changed { + break } + } + checker.suppressDiagnostics = false - checker.checkGlobalInitializers(module) - state[module] = 2 + checker.resetGlobalInitialization(modules) + for _, module := range modules { + checker.checkGlobalInitializers(module, false) } +} +func (checker *checker) resetGlobalInitialization(modules []*Module) { for _, module := range modules { - checkModule(module) + if module == nil || module.Symbols == nil { + continue + } + for _, symbol := range module.Symbols.Globals { + symbol.initialized = false + } } } -func (checker *checker) checkGlobalInitializers(module *Module) { +func (checker *checker) checkGlobalInitializers(module *Module, updateTypes bool) bool { + if module == nil || module.Syntax == nil { + return false + } + + changed := false for _, declaration := range module.Syntax.Declarations { global, ok := declaration.(*ast.GlobalAssignment) if !ok || global == nil { @@ -222,12 +234,31 @@ func (checker *checker) checkGlobalInitializers(module *Module) { } path, _ := globalPath(global.Target) if symbol, ok := module.Symbols.Globals[path]; ok { - symbol.Type = typ + if updateTypes && !sameType(symbol.Type, typ) { + symbol.Type = typ + changed = true + } symbol.initialized = true module.ResolvedVariables[global.Target] = symbol } checker.checkVariableAccesses(module, nil, global.Target) } + return changed +} + +func sameType(left Type, right Type) bool { + if left.Kind != right.Kind || left.Name != right.Name || left.incompatible != right.incompatible { + return false + } + if len(left.Arguments) != len(right.Arguments) { + return false + } + for index := range left.Arguments { + if !sameType(left.Arguments[index], right.Arguments[index]) { + return false + } + } + return true } func endsWithEmptyIndex(variable *ast.VariableExpr) bool { @@ -341,5 +372,8 @@ func (checker *checker) report( message string, hint string, ) { + if checker.suppressDiagnostics { + return + } checker.diagnostics = append(checker.diagnostics, semanticDiagnostic(module, node, code, message, hint)) } diff --git a/src/internal/sema/statements.go b/src/internal/sema/statements.go index 41e6334..7123264 100644 --- a/src/internal/sema/statements.go +++ b/src/internal/sema/statements.go @@ -103,7 +103,7 @@ func (checker *checker) checkStatement( if statement == nil { return true } - checker.checkLoopRange(module, currentScope, statement, context) + return checker.checkLoopRange(module, currentScope, statement, context) case *ast.LoopPlayersStmt: if statement == nil { return true @@ -233,7 +233,21 @@ func (checker *checker) checkAdd(module *Module, currentScope *scope, statement targetType := checker.checkVariable(module, currentScope, target) if len(target.Accesses) > 0 { if _, empty := target.Accesses[len(target.Accesses)-1].(*ast.EmptyIndexAccess); empty { - if targetType.Kind != TypeList || len(targetType.Arguments) == 0 { + if targetType.IsUnknown() && !targetType.incompatible || + valueType.IsUnknown() && !valueType.incompatible { + return + } + if targetType.incompatible { + checker.typeMismatch(module, statement.Value, + fmt.Sprintf("Type mismatch: cannot add %s to %s[].", valueType.String(), targetType.String())) + return + } + if targetType.Kind != TypeList { + checker.typeMismatch(module, statement.Value, + fmt.Sprintf("Type mismatch: cannot add %s to %s[].", valueType.String(), targetType.String())) + return + } + if len(targetType.Arguments) == 0 { return } targetType = targetType.Arguments[0] @@ -248,6 +262,9 @@ func (checker *checker) checkAdd(module *Module, currentScope *scope, statement } func addCompatible(target Type, value Type) bool { + if target.incompatible || value.incompatible { + return false + } if target.IsUnknown() || value.IsUnknown() { return true } @@ -316,7 +333,7 @@ func (checker *checker) checkLoopRange( currentScope *scope, statement *ast.LoopRangeStmt, context flowContext, -) { +) bool { start := checker.checkExpression(module, currentScope, statement.Start) end := checker.checkExpression(module, currentScope, statement.End) checker.requireNumeric(module, statement.Start, start) @@ -326,7 +343,11 @@ func (checker *checker) checkLoopRange( loopScope := newInjectedScope(isolatedFlowScope(currentScope)) loopScope.defineName("loop.index", Type{Kind: TypeInt}) loopScope.defineName("loop.value", valueType) - checker.checkBlock(module, loopScope, statement.Body, context) + fallsThrough := checker.checkBlock(module, loopScope, statement.Body, context) + if fallsThrough { + mergeFlowScopes(currentScope, []*scope{loopScope}) + } + return fallsThrough } func (checker *checker) checkLoopPlayers( diff --git a/src/internal/sema/symbols.go b/src/internal/sema/symbols.go index 36bc811..a99ba2a 100644 --- a/src/internal/sema/symbols.go +++ b/src/internal/sema/symbols.go @@ -1,6 +1,11 @@ package sema -import "github.com/puff-lang/puff/internal/ast" +import ( + "strconv" + "strings" + + "github.com/puff-lang/puff/internal/ast" +) type FunctionSymbol struct { Name string @@ -42,11 +47,11 @@ func globalPath(variable *ast.VariableExpr) (string, int) { path := variable.Name.Name depth := 0 for _, access := range variable.Accesses { - field, ok := access.(*ast.FieldAccess) + part, ok := staticGlobalAccess(access) if !ok { break } - path += "." + field.Field.Name + path += part depth++ } return path, depth @@ -60,11 +65,11 @@ func (symbols *SymbolTable) lookupGlobal(variable *ast.VariableExpr) *VariableSy path := variable.Name.Name symbol := symbols.Globals[path] for _, access := range variable.Accesses { - field, ok := access.(*ast.FieldAccess) + part, ok := staticGlobalAccess(access) if !ok { break } - path += "." + field.Field.Name + path += part if candidate := symbols.Globals[path]; candidate != nil { symbol = candidate } @@ -72,6 +77,46 @@ func (symbols *SymbolTable) lookupGlobal(variable *ast.VariableExpr) *VariableSy return symbol } +func staticGlobalAccess(access ast.VariableAccess) (string, bool) { + switch access := access.(type) { + case *ast.FieldAccess: + return "." + access.Field.Name, true + case *ast.IndexAccess: + value, ok := staticIndexValue(access.Index) + if !ok { + return "", false + } + return "[" + value + "]", true + default: + return "", false + } +} + +func staticIndexValue(expression ast.Expression) (string, bool) { + switch expression := expression.(type) { + case *ast.StringExpr: + var value strings.Builder + for _, part := range expression.Parts { + text, ok := part.(*ast.StringText) + if !ok { + return "", false + } + value.WriteString(text.Value) + } + return strconv.Quote(value.String()), true + case *ast.IntLiteral: + return strconv.FormatInt(expression.Value, 10), true + case *ast.FloatLiteral: + return strconv.FormatFloat(expression.Value, 'g', -1, 64), true + case *ast.BoolLiteral: + return strconv.FormatBool(expression.Value), true + case *ast.NilLiteral: + return "nil", true + default: + return "", false + } +} + type scope struct { parent *scope owner *scope From a0280eb111eb868b2ff1ccede2c1827bee19cf9b Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 19:09:16 -0300 Subject: [PATCH 08/12] test(sema): cover cyclic and indexed edge cases --- src/internal/sema/checker_integration_test.go | 41 ++++ src/internal/sema/global_cycle_index_test.go | 213 ++++++++++++++++++ src/internal/sema/loop_add_edge_test.go | 164 ++++++++++++++ .../testdata/checker/final-edges/puff.toml | 5 + .../final-edges/src/a_index_private_last.puff | 2 + .../src/b_index_private_last_use.puff | 4 + .../final-edges/src/c_index_public_last.puff | 2 + .../src/d_index_public_last_use.puff | 4 + .../checker/final-edges/src/e_cycle_seed.puff | 3 + .../checker/final-edges/src/f_cycle_copy.puff | 7 + .../final-edges/src/g_range_state.puff | 7 + .../src/h_add_scalar_list_target.puff | 4 + .../final-edges/src/i_add_merged_type.puff | 8 + 13 files changed, 464 insertions(+) create mode 100644 src/internal/sema/global_cycle_index_test.go create mode 100644 src/internal/sema/loop_add_edge_test.go create mode 100644 src/internal/sema/testdata/checker/final-edges/puff.toml create mode 100644 src/internal/sema/testdata/checker/final-edges/src/a_index_private_last.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/b_index_private_last_use.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/c_index_public_last.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/d_index_public_last_use.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/e_cycle_seed.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/f_cycle_copy.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/g_range_state.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/h_add_scalar_list_target.puff create mode 100644 src/internal/sema/testdata/checker/final-edges/src/i_add_merged_type.puff diff --git a/src/internal/sema/checker_integration_test.go b/src/internal/sema/checker_integration_test.go index bf66661..a6e1642 100644 --- a/src/internal/sema/checker_integration_test.go +++ b/src/internal/sema/checker_integration_test.go @@ -214,6 +214,47 @@ func TestCheckIntegrationReportsDocumentedDiagnosticsWithoutCascades(t *testing. }, }, }, + { + name: "final semantic edges", + fixture: "final-edges", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeUndefinedVariable, + file: "b_index_private_last_use.puff", + line: 4, + message: "Undefined variable: private_last.$stats", + hint: "Declare it before using it: private_last.$stats = 0", + }, + { + code: diagnostic.CodeUndefinedVariable, + file: "d_index_public_last_use.puff", + line: 4, + message: "Undefined variable: public_last.$stats", + hint: "Declare it before using it: public_last.$stats = 0", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "f_cycle_copy.puff", + line: 6, + message: "Type mismatch: cannot return int as string.", + hint: "Return a value compatible with string.", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "h_add_scalar_list_target.puff", + line: 3, + message: "Type mismatch: cannot add int to int[].", + hint: "Convert one value or use compatible types.", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "i_add_merged_type.puff", + line: 7, + message: "Type mismatch: cannot add int to unknown.", + hint: "Convert one value or use compatible types.", + }, + }, + }, } for _, test := range tests { diff --git a/src/internal/sema/global_cycle_index_test.go b/src/internal/sema/global_cycle_index_test.go new file mode 100644 index 0000000..ae54fcd --- /dev/null +++ b/src/internal/sema/global_cycle_index_test.go @@ -0,0 +1,213 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/token" +) + +func TestCheckGlobalStaticIndexVisibilityIsOrderIndependent(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "declaration order" + if reverse { + name = "reverse declaration order" + } + t.Run(name, func(t *testing.T) { + declarations := []ast.Declaration{ + indexedGlobal("stats", nttString("coins", 1), true, nttInt(10, 1), 1), + indexedGlobal("stats", nttString("secret", 2), false, nttString("hidden", 2), 2), + indexedGlobal("stats", nttInt(1, 3), true, nttString("one", 3), 3), + indexedGlobal("stats", &ast.BoolLiteral{NodeBase: nttBase(4), Value: true}, true, + &ast.BoolLiteral{NodeBase: nttBase(4), Value: true}, 4), + indexedGlobal("stats", &ast.NilLiteral{NodeBase: nttBase(5)}, true, nttFloat(2.5, 5), 5), + } + if reverse { + for left, right := 0, len(declarations)-1; left < right; left, right = left+1, right-1 { + declarations[left], declarations[right] = declarations[right], declarations[left] + } + } + library := nttModule("lib/stats.puff", declarations...) + + coins := importedIndexedVariable("stats", "stats", nttString("coins", 10), 10) + secret := importedIndexedVariable("stats", "stats", nttString("secret", 11), 11) + number := importedIndexedVariable("stats", "stats", nttInt(1, 12), 12) + flag := importedIndexedVariable("stats", "stats", + &ast.BoolLiteral{NodeBase: nttBase(13), Value: true}, 13) + nilValue := importedIndexedVariable("stats", "stats", + &ast.NilLiteral{NodeBase: nttBase(14)}, 14) + main := nttModule("main.puff", nttEvent("load", + nttExprStmt(coins, 10), + nttExprStmt(secret, 11), + nttExprStmt(number, 12), + nttExprStmt(flag, 13), + nttExprStmt(nilValue, 14), + )) + main.Imports["stats"] = &Import{Prefix: "stats", Target: library} + + result := Check(nttProject(main, library)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: stats.$stats", + Hint: "Declare it before using it: stats.$stats = 0", + File: "main.puff", + Span: secret.Span(), + }) + assertExpressionKind(t, main, coins, TypeInt) + assertExpressionKind(t, main, number, TypeString) + assertExpressionKind(t, main, flag, TypeBool) + assertExpressionKind(t, main, nilValue, TypeFloat) + }) + } +} + +func TestCheckGlobalDynamicIndexFallsBackToRootSymbol(t *testing.T) { + library := nttModule("lib/stats.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: nttVariable("stats", false, 1), + Value: &ast.MapExpr{NodeBase: nttBase(1), Entries: []ast.MapEntry{{ + Key: nttInt(2, 1), + Value: nttString("two", 1), + }}}, + }) + index := &ast.BinaryExpr{ + NodeBase: nttBase(3), + Left: nttInt(1, 3), + Operator: token.Plus, + Right: nttInt(1, 3), + } + read := importedIndexedVariable("stats", "stats", index, 3) + main := nttModule("main.puff", nttEvent("load", nttExprStmt(read, 3))) + main.Imports["stats"] = &Import{Prefix: "stats", Target: library} + + result := Check(nttProject(main, library)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + assertExpressionKind(t, main, read, TypeString) + if main.ResolvedVariables[read] != library.Symbols.Globals["stats"] { + t.Fatal("expected dynamic index lookup to resolve through root global") + } +} + +func TestCheckGlobalInitializerCycleConvergesWithoutDuplicateDiagnostics(t *testing.T) { + for _, reverseModules := range []bool{false, true} { + name := "module order" + if reverseModules { + name = "reverse module order" + } + t.Run(name, func(t *testing.T) { + fromB := nttImportedVariable("b", "copy", 2) + moduleA := nttModule("a.puff", + &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: nttVariable("seed", false, 1), + Value: nttInt(1, 1), + }, + &ast.GlobalAssignment{ + NodeBase: nttBase(2), + Public: true, + Target: nttVariable("fromB", false, 2), + Value: fromB, + }, + ) + + fromA := nttImportedVariable("a", "seed", 1) + missing := nttVariable("missing", false, 2) + moduleB := nttModule("b.puff", + &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: nttVariable("copy", false, 1), + Value: fromA, + }, + &ast.GlobalAssignment{ + NodeBase: nttBase(2), + Target: nttVariable("broken", false, 2), + Value: missing, + }, + returningFunction("value", nttType("string", 3), nttVariable("copy", false, 4)), + ) + moduleA.Imports["b"] = &Import{Prefix: "b", Target: moduleB} + moduleB.Imports["a"] = &Import{Prefix: "a", Target: moduleA} + + modules := []*Module{moduleA, moduleB} + if reverseModules { + modules = []*Module{moduleB, moduleA} + } + result := Check(nttProject(modules...)) + + if len(result.Diagnostics) != 2 { + t.Fatalf("expected two diagnostics without fixpoint duplicates, got %#v", result.Diagnostics) + } + assertDiagnosticCount(t, result.Diagnostics, diagnostic.CodeUndefinedVariable, 1) + assertDiagnosticCount(t, result.Diagnostics, diagnostic.CodeTypeMismatch, 1) + if got := moduleA.Symbols.Globals["fromB"].Type.Kind; got != TypeInt { + t.Fatalf("expected cycle to converge from seed to int, got %s", got) + } + if got := moduleB.Symbols.Globals["copy"].Type.Kind; got != TypeInt { + t.Fatalf("expected imported cycle value to converge to int, got %s", got) + } + if moduleA.ResolvedVariables[fromB] == nil || moduleB.ResolvedVariables[fromA] == nil { + t.Fatal("expected converged imported globals to remain resolved") + } + }) + } +} + +func indexedGlobal( + root string, + index ast.Expression, + public bool, + value ast.Expression, + line int, +) *ast.GlobalAssignment { + return &ast.GlobalAssignment{ + NodeBase: nttBase(line), + Public: public, + Target: indexedVariable(root, index, line), + Value: value, + } +} + +func importedIndexedVariable(prefix string, root string, index ast.Expression, line int) *ast.VariableExpr { + variable := nttImportedVariable(prefix, root, line) + variable.Accesses = append(variable.Accesses, &ast.IndexAccess{NodeBase: nttBase(line), Index: index}) + return variable +} + +func indexedVariable(root string, index ast.Expression, line int) *ast.VariableExpr { + variable := nttVariable(root, false, line) + variable.Accesses = append(variable.Accesses, &ast.IndexAccess{NodeBase: nttBase(line), Index: index}) + return variable +} + +func assertExpressionKind(t *testing.T, module *Module, expression ast.Expression, want TypeKind) { + t.Helper() + if got := module.ExpressionTypes[expression].Kind; got != want { + t.Fatalf("expected expression type %s, got %s", want, got) + } +} + +func assertDiagnosticCount( + t *testing.T, + diagnostics []diagnostic.Diagnostic, + code diagnostic.Code, + want int, +) { + t.Helper() + got := 0 + for _, item := range diagnostics { + if item.Code == code { + got++ + } + } + if got != want { + t.Fatalf("expected %d %s diagnostics, got %d: %#v", want, code, got, diagnostics) + } +} diff --git a/src/internal/sema/loop_add_edge_test.go b/src/internal/sema/loop_add_edge_test.go new file mode 100644 index 0000000..262d23b --- /dev/null +++ b/src/internal/sema/loop_add_edge_test.go @@ -0,0 +1,164 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +func TestCheckLoopRangePropagatesGuaranteedBodyState(t *testing.T) { + updatedRead := nttVariable("updated", true, 7) + createdRead := nttVariable("created", true, 8) + loop := &ast.LoopRangeStmt{ + NodeBase: nttBase(3), + Start: nttInt(1, 3), + End: nttInt(1, 3), + Body: ast.Block{Statements: []ast.Statement{ + localAssignment("updated", nttString("after", 4), 4), + localAssignment("created", nttInt(2, 5), 5), + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("updated", nttInt(1, 2), 2), + loop, + nttExprStmt(updatedRead, 7), + nttExprStmt(createdRead, 8), + )))) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if typ := result.Project.Modules[0].ExpressionTypes[updatedRead]; typ.Kind != TypeString { + t.Fatalf("expected range body to update local to string, got %s", typ.String()) + } + if typ := result.Project.Modules[0].ExpressionTypes[createdRead]; typ.Kind != TypeInt { + t.Fatalf("expected range body to define int local, got %s", typ.String()) + } +} + +func TestCheckLoopRangePropagatesGuaranteedReturn(t *testing.T) { + function := efFunction("value", efType("int", 1), []ast.Statement{ + &ast.LoopRangeStmt{ + NodeBase: nttBase(2), + Start: nttInt(1, 2), + End: nttInt(1, 2), + Body: ast.Block{Statements: []ast.Statement{ + &ast.ReturnStmt{NodeBase: nttBase(3), Value: nttInt(1, 3)}, + }}, + }, + }, 1) + + result := Check(nttProject(nttModule("main.puff", function))) + + nttAssertNoDiagnostics(t, result.Diagnostics) +} + +func TestCheckNonGuaranteedLoopsKeepConservativeLocalState(t *testing.T) { + tests := map[string]func(ast.Block) ast.Statement{ + "times": func(body ast.Block) ast.Statement { + return &ast.LoopTimesStmt{NodeBase: nttBase(3), Count: nttInt(1, 3), Body: body} + }, + "players": func(body ast.Block) ast.Statement { + return &ast.LoopPlayersStmt{NodeBase: nttBase(3), Body: body} + }, + "entities": func(body ast.Block) ast.Statement { + return &ast.LoopEntitiesStmt{ + NodeBase: nttBase(3), + Radius: nttInt(10, 3), + Around: nttString("spawn", 3), + Body: body, + } + }, + } + + for name, makeLoop := range tests { + t.Run(name, func(t *testing.T) { + stableRead := nttVariable("stable", true, 7) + createdRead := nttVariable("created", true, 8) + body := ast.Block{Statements: []ast.Statement{ + localAssignment("stable", nttString("changed", 4), 4), + localAssignment("created", nttInt(2, 5), 5), + }} + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("stable", nttInt(1, 2), 2), + makeLoop(body), + nttExprStmt(stableRead, 7), + nttExprStmt(createdRead, 8), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, undefinedLocalDiagnostic(createdRead)) + if typ := result.Project.Modules[0].ExpressionTypes[stableRead]; typ.Kind != TypeInt { + t.Fatalf("expected conservative loop state to preserve int, got %s", typ.String()) + } + }) + } +} + +func TestCheckAddToEmptyIndexRequiresKnownListTarget(t *testing.T) { + t.Run("rejects known non-list target", func(t *testing.T) { + value := nttInt(2, 3) + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("values", nttInt(1, 2), 2), + addStatement(value, listTarget("values", 3), 3), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot add int to int[].", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: value.Span(), + }) + }) + + t.Run("suppresses cascade for unknown target", func(t *testing.T) { + target := listTarget("missing", 3) + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + addStatement(nttInt(2, 3), target, 3), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, undefinedLocalDiagnostic(target)) + }) + + t.Run("suppresses cascade for unknown value", func(t *testing.T) { + value := nttVariable("missing", true, 3) + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + localAssignment("values", nttInt(1, 2), 2), + addStatement(value, listTarget("values", 3), 3), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, undefinedLocalDiagnostic(value)) + }) +} + +func TestCheckAddRejectsIncompatibleMergedBranchType(t *testing.T) { + value := nttInt(1, 8) + statement := &ast.IfStmt{ + NodeBase: nttBase(3), + Condition: &ast.BoolLiteral{NodeBase: nttBase(3), Value: true}, + Then: ast.Block{Statements: []ast.Statement{ + localAssignment("value", nttInt(1, 4), 4), + }}, + Else: &ast.Block{Statements: []ast.Statement{ + localAssignment("value", nttString("wrong", 6), 6), + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + statement, + addStatement(value, nttVariable("value", true, 8), 8), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot add int to unknown.", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: value.Span(), + }) +} diff --git a/src/internal/sema/testdata/checker/final-edges/puff.toml b/src/internal/sema/testdata/checker/final-edges/puff.toml new file mode 100644 index 0000000..c2e0415 --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-final-edges" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/final-edges/src/a_index_private_last.puff b/src/internal/sema/testdata/checker/final-edges/src/a_index_private_last.puff new file mode 100644 index 0000000..feb847f --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/a_index_private_last.puff @@ -0,0 +1,2 @@ +pub $stats["coins"] = 1 +$stats["secret"] = 2 diff --git a/src/internal/sema/testdata/checker/final-edges/src/b_index_private_last_use.puff b/src/internal/sema/testdata/checker/final-edges/src/b_index_private_last_use.puff new file mode 100644 index 0000000..bf1852d --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/b_index_private_last_use.puff @@ -0,0 +1,4 @@ +require "a_index_private_last" as private_last + +$public_coins = private_last.$stats["coins"] +$private_secret = private_last.$stats["secret"] diff --git a/src/internal/sema/testdata/checker/final-edges/src/c_index_public_last.puff b/src/internal/sema/testdata/checker/final-edges/src/c_index_public_last.puff new file mode 100644 index 0000000..69a1dd3 --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/c_index_public_last.puff @@ -0,0 +1,2 @@ +$stats["secret"] = 2 +pub $stats["coins"] = 1 diff --git a/src/internal/sema/testdata/checker/final-edges/src/d_index_public_last_use.puff b/src/internal/sema/testdata/checker/final-edges/src/d_index_public_last_use.puff new file mode 100644 index 0000000..0fffcc0 --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/d_index_public_last_use.puff @@ -0,0 +1,4 @@ +require "c_index_public_last" as public_last + +$public_coins = public_last.$stats["coins"] +$private_secret = public_last.$stats["secret"] diff --git a/src/internal/sema/testdata/checker/final-edges/src/e_cycle_seed.puff b/src/internal/sema/testdata/checker/final-edges/src/e_cycle_seed.puff new file mode 100644 index 0000000..a3760ad --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/e_cycle_seed.puff @@ -0,0 +1,3 @@ +require "f_cycle_copy" as cycle_copy + +pub $seed = 1 diff --git a/src/internal/sema/testdata/checker/final-edges/src/f_cycle_copy.puff b/src/internal/sema/testdata/checker/final-edges/src/f_cycle_copy.puff new file mode 100644 index 0000000..a8c951d --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/f_cycle_copy.puff @@ -0,0 +1,7 @@ +require "e_cycle_seed" as cycle_seed + +pub $copy = cycle_seed.$seed + +fun cycleValue -> string + return $copy +end diff --git a/src/internal/sema/testdata/checker/final-edges/src/g_range_state.puff b/src/internal/sema/testdata/checker/final-edges/src/g_range_state.puff new file mode 100644 index 0000000..487f5e3 --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/g_range_state.puff @@ -0,0 +1,7 @@ +fun rangeValue -> string + $_value = 1 + loop numbers from 1 to 1 + $_value = "ok" + end + return $_value +end diff --git a/src/internal/sema/testdata/checker/final-edges/src/h_add_scalar_list_target.puff b/src/internal/sema/testdata/checker/final-edges/src/h_add_scalar_list_target.puff new file mode 100644 index 0000000..858d7d9 --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/h_add_scalar_list_target.puff @@ -0,0 +1,4 @@ +fun appendToScalar + $_values = 1 + add 2 to $_values[] +end diff --git a/src/internal/sema/testdata/checker/final-edges/src/i_add_merged_type.puff b/src/internal/sema/testdata/checker/final-edges/src/i_add_merged_type.puff new file mode 100644 index 0000000..3d36a6a --- /dev/null +++ b/src/internal/sema/testdata/checker/final-edges/src/i_add_merged_type.puff @@ -0,0 +1,8 @@ +fun addAfterIncompatibleMerge(flag: bool) + if flag + $_value = 1 + else + $_value = "wrong" + end + add 1 to $_value +end From 86240deb32a86de0972203d35d2e5d404a3c4c5a Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 19:35:14 -0300 Subject: [PATCH 09/12] fix(sema): close inference and fixpoint gaps --- src/internal/sema/checker.go | 37 +++++++++++++++++++-- src/internal/sema/expressions.go | 56 ++++++++++++++++++++------------ src/internal/sema/symbols.go | 30 ++++++++++++++--- src/internal/sema/types.go | 5 +++ 4 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/internal/sema/checker.go b/src/internal/sema/checker.go index ac89d00..a058f8e 100644 --- a/src/internal/sema/checker.go +++ b/src/internal/sema/checker.go @@ -13,6 +13,12 @@ type checker struct { project *Project diagnostics []diagnostic.Diagnostic suppressDiagnostics bool + unresolvedGlobal *globalDependencyState +} + +type globalDependencyState struct { + unresolved bool + reported bool } func Check(project *Project) Result { @@ -196,8 +202,9 @@ func (checker *checker) checkGlobalInitializersInDependencyOrder() { checker.suppressDiagnostics = false checker.resetGlobalInitialization(modules) + checker.resetGlobalReports(modules) for _, module := range modules { - checker.checkGlobalInitializers(module, false) + checker.checkGlobalInitializers(module, true) } } @@ -212,6 +219,17 @@ func (checker *checker) resetGlobalInitialization(modules []*Module) { } } +func (checker *checker) resetGlobalReports(modules []*Module) { + for _, module := range modules { + if module == nil || module.Symbols == nil { + continue + } + for _, symbol := range module.Symbols.Globals { + symbol.reported = false + } + } +} + func (checker *checker) checkGlobalInitializers(module *Module, updateTypes bool) bool { if module == nil || module.Syntax == nil { return false @@ -224,7 +242,10 @@ func (checker *checker) checkGlobalInitializers(module *Module, updateTypes bool continue } + dependency := globalDependencyState{} + checker.unresolvedGlobal = &dependency typ := checker.checkExpression(module, nil, global.Value) + checker.unresolvedGlobal = nil if global.Target == nil || global.Target.Local { continue } @@ -238,6 +259,17 @@ func (checker *checker) checkGlobalInitializers(module *Module, updateTypes bool symbol.Type = typ changed = true } + resolution := globalResolved + if dependency.unresolved { + resolution = globalUnresolved + } + if symbol.resolution != resolution { + symbol.resolution = resolution + changed = true + } + if !checker.suppressDiagnostics { + symbol.reported = dependency.reported + } symbol.initialized = true module.ResolvedVariables[global.Target] = symbol } @@ -247,7 +279,8 @@ func (checker *checker) checkGlobalInitializers(module *Module, updateTypes bool } func sameType(left Type, right Type) bool { - if left.Kind != right.Kind || left.Name != right.Name || left.incompatible != right.incompatible { + if left.Kind != right.Kind || left.Name != right.Name || + left.incompatible != right.incompatible || left.placeholder != right.placeholder { return false } if len(left.Arguments) != len(right.Arguments) { diff --git a/src/internal/sema/expressions.go b/src/internal/sema/expressions.go index 4e3eec9..1cfdb16 100644 --- a/src/internal/sema/expressions.go +++ b/src/internal/sema/expressions.go @@ -292,10 +292,22 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia return Type{Kind: TypeUnknown} } if _, isGlobalDeclaration := symbol.Declaration.(*ast.GlobalAssignment); isGlobalDeclaration && - symbol.Module == module && !symbol.initialized { - checker.report(module, variable, diagnostic.CodeUndefinedVariable, - fmt.Sprintf("Undefined variable: %s", variableName(variable)), - fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) + (symbol.Module == module && !symbol.initialized || symbol.resolution == globalUnresolved) { + if checker.unresolvedGlobal != nil { + checker.unresolvedGlobal.unresolved = true + checker.unresolvedGlobal.reported = symbol.reported + } + if !symbol.reported { + checker.report(module, variable, diagnostic.CodeUndefinedVariable, + fmt.Sprintf("Undefined variable: %s", variableName(variable)), + fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) + if !checker.suppressDiagnostics { + symbol.reported = true + if checker.unresolvedGlobal != nil { + checker.unresolvedGlobal.reported = true + } + } + } return Type{Kind: TypeUnknown} } @@ -357,29 +369,20 @@ func variableName(variable *ast.VariableExpr) string { } func (checker *checker) checkList(module *Module, currentScope *scope, expression *ast.ListExpr) Type { - elementType := Type{Kind: TypeUnknown} - for index, element := range expression.Elements { + elementType := inferencePlaceholder() + for _, element := range expression.Elements { current := checker.checkExpression(module, currentScope, element) - if index == 0 { - elementType = current - } else { - elementType = mergeInferredTypes(elementType, current) - } + elementType = mergeInferredTypes(elementType, current) } return Type{Kind: TypeList, Arguments: []Type{elementType}} } func (checker *checker) checkMap(module *Module, currentScope *scope, expression *ast.MapExpr) Type { - keyType := Type{Kind: TypeUnknown} - valueType := Type{Kind: TypeUnknown} - for index, entry := range expression.Entries { + keyType := inferencePlaceholder() + valueType := inferencePlaceholder() + for _, entry := range expression.Entries { key := checker.checkExpression(module, currentScope, entry.Key) value := checker.checkExpression(module, currentScope, entry.Value) - if index == 0 { - keyType = key - valueType = value - continue - } keyType = mergeInferredTypes(keyType, key) valueType = mergeInferredTypes(valueType, value) } @@ -387,12 +390,25 @@ func (checker *checker) checkMap(module *Module, currentScope *scope, expression } func mergeInferredTypes(left Type, right Type) Type { - if left.IsUnknown() && !left.incompatible || right.IsUnknown() && !right.incompatible { + if (left.IsUnknown() && !left.placeholder && !left.incompatible) || + (right.IsUnknown() && !right.placeholder && !right.incompatible) { return Type{Kind: TypeUnknown} } if left.incompatible || right.incompatible { return Type{Kind: TypeUnknown, incompatible: true} } + if left.IsUnknown() { + if left.placeholder { + return right + } + return left + } + if right.IsUnknown() { + if right.placeholder { + return left + } + return right + } if numeric := numericType(left, right); !numeric.IsUnknown() { return numeric } diff --git a/src/internal/sema/symbols.go b/src/internal/sema/symbols.go index a99ba2a..103e79b 100644 --- a/src/internal/sema/symbols.go +++ b/src/internal/sema/symbols.go @@ -5,6 +5,14 @@ import ( "strings" "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/token" +) + +type globalResolution uint8 + +const ( + globalUnresolved globalResolution = iota + globalResolved ) type FunctionSymbol struct { @@ -25,6 +33,8 @@ type VariableSymbol struct { Local bool AccessDepth int initialized bool + resolution globalResolution + reported bool } type SymbolTable struct { @@ -103,13 +113,25 @@ func staticIndexValue(expression ast.Expression) (string, bool) { } value.WriteString(text.Value) } - return strconv.Quote(value.String()), true + return "string:" + strconv.Quote(value.String()), true case *ast.IntLiteral: - return strconv.FormatInt(expression.Value, 10), true + return "int:" + strconv.FormatInt(expression.Value, 10), true case *ast.FloatLiteral: - return strconv.FormatFloat(expression.Value, 'g', -1, 64), true + return "float:" + strconv.FormatFloat(expression.Value, 'g', -1, 64), true + case *ast.UnaryExpr: + if expression.Operator != token.Minus { + return "", false + } + switch operand := expression.Operand.(type) { + case *ast.IntLiteral: + return "int:" + strconv.FormatInt(-operand.Value, 10), true + case *ast.FloatLiteral: + return "float:" + strconv.FormatFloat(-operand.Value, 'g', -1, 64), true + default: + return "", false + } case *ast.BoolLiteral: - return strconv.FormatBool(expression.Value), true + return "bool:" + strconv.FormatBool(expression.Value), true case *ast.NilLiteral: return "nil", true default: diff --git a/src/internal/sema/types.go b/src/internal/sema/types.go index 3ee49ff..b50a4f4 100644 --- a/src/internal/sema/types.go +++ b/src/internal/sema/types.go @@ -25,6 +25,7 @@ type Type struct { Name string Arguments []Type incompatible bool + placeholder bool } func (typ Type) String() string { @@ -47,6 +48,10 @@ func (typ Type) IsUnknown() bool { return typ.Kind == TypeUnknown } +func inferencePlaceholder() Type { + return Type{Kind: TypeUnknown, placeholder: true} +} + var builtInTypes = map[string]TypeKind{ "nil": TypeNil, "bool": TypeBool, From 7f853b69b73441c2fde793b7058207e67226fd61 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 19:35:16 -0300 Subject: [PATCH 10/12] test(sema): cover final semantic edge cases --- src/internal/sema/checker_integration_test.go | 41 ++++ .../sema/fixpoint_reassignment_test.go | 194 ++++++++++++++++++ src/internal/sema/nested_collection_test.go | 134 ++++++++++++ .../testdata/checker/closure-edges/puff.toml | 5 + .../closure-edges/src/a_cycle_no_seed.puff | 7 + .../closure-edges/src/b_cycle_no_seed.puff | 3 + .../src/c_negative_private_last.puff | 2 + .../src/d_negative_private_last_use.puff | 4 + .../src/e_negative_public_last.puff | 2 + .../src/f_negative_public_last_use.puff | 4 + .../src/g_nested_collection.puff | 3 + .../src/h_top_level_reassignment.puff | 7 + 12 files changed, 406 insertions(+) create mode 100644 src/internal/sema/fixpoint_reassignment_test.go create mode 100644 src/internal/sema/nested_collection_test.go create mode 100644 src/internal/sema/testdata/checker/closure-edges/puff.toml create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/a_cycle_no_seed.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/b_cycle_no_seed.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/c_negative_private_last.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/d_negative_private_last_use.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/e_negative_public_last.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/f_negative_public_last_use.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/g_nested_collection.puff create mode 100644 src/internal/sema/testdata/checker/closure-edges/src/h_top_level_reassignment.puff diff --git a/src/internal/sema/checker_integration_test.go b/src/internal/sema/checker_integration_test.go index a6e1642..38876d0 100644 --- a/src/internal/sema/checker_integration_test.go +++ b/src/internal/sema/checker_integration_test.go @@ -255,6 +255,47 @@ func TestCheckIntegrationReportsDocumentedDiagnosticsWithoutCascades(t *testing. }, }, }, + { + name: "closure semantic edges", + fixture: "closure-edges", + expected: []expectedSemanticDiagnostic{ + { + code: diagnostic.CodeUndefinedVariable, + file: "a_cycle_no_seed.puff", + line: 3, + message: "Undefined variable: cycle_b.$y", + hint: "Declare it before using it: cycle_b.$y = 0", + }, + { + code: diagnostic.CodeUndefinedVariable, + file: "d_negative_private_last_use.puff", + line: 4, + message: "Undefined variable: private_last.$stats", + hint: "Declare it before using it: private_last.$stats = 0", + }, + { + code: diagnostic.CodeUndefinedVariable, + file: "f_negative_public_last_use.puff", + line: 4, + message: "Undefined variable: public_last.$stats", + hint: "Declare it before using it: public_last.$stats = 0", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "g_nested_collection.puff", + line: 2, + message: "Type mismatch: cannot return list> as list>.", + hint: "Return a value compatible with list>.", + }, + { + code: diagnostic.CodeTypeMismatch, + file: "h_top_level_reassignment.puff", + line: 6, + message: "Type mismatch: cannot return string as int.", + hint: "Return a value compatible with int.", + }, + }, + }, } for _, test := range tests { diff --git a/src/internal/sema/fixpoint_reassignment_test.go b/src/internal/sema/fixpoint_reassignment_test.go new file mode 100644 index 0000000..2d10df1 --- /dev/null +++ b/src/internal/sema/fixpoint_reassignment_test.go @@ -0,0 +1,194 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/token" +) + +func TestCheckUnseededImportedGlobalCycleReportsDependenciesOnce(t *testing.T) { + for _, reverseModules := range []bool{false, true} { + name := "module order" + if reverseModules { + name = "reverse module order" + } + t.Run(name, func(t *testing.T) { + fromB := nttImportedVariable("b", "y", 1) + moduleA := nttModule("a.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: nttVariable("x", false, 1), + Value: fromB, + }) + fromA := nttImportedVariable("a", "x", 1) + moduleB := nttModule("b.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: nttVariable("y", false, 1), + Value: fromA, + }) + moduleA.Imports["b"] = &Import{Prefix: "b", Target: moduleB} + moduleB.Imports["a"] = &Import{Prefix: "a", Target: moduleA} + + modules := []*Module{moduleA, moduleB} + if reverseModules { + modules = []*Module{moduleB, moduleA} + } + result := Check(nttProject(modules...)) + + if len(result.Diagnostics) != 1 { + t.Fatalf("expected one root diagnostic for the unresolved cycle, got %#v", result.Diagnostics) + } + assertUndefinedGlobalDependency(t, result.Diagnostics[0], "a.puff", "b.$y", fromB) + if moduleA.Symbols.Globals["x"].resolution != globalUnresolved || + moduleB.Symbols.Globals["y"].resolution != globalUnresolved { + t.Fatal("expected the unseeded cycle to remain explicitly unresolved") + } + if !moduleA.Symbols.Globals["x"].reported || !moduleB.Symbols.Globals["y"].reported { + t.Fatal("expected the root diagnostic to suppress duplicate cycle reports") + } + }) + } +} + +func TestCheckDeferredUnknownGlobalIsNotAnUnresolvedDependency(t *testing.T) { + deferred := &ast.PatternExpr{NodeBase: nttBase(1)} + library := nttModule("lib/deferred.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: nttVariable("value", false, 1), + Value: deferred, + }) + read := nttImportedVariable("deferred", "value", 1) + main := nttModule("main.puff", &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Target: nttVariable("copy", false, 1), + Value: read, + }) + main.Imports["deferred"] = &Import{Prefix: "deferred", Target: library} + + result := Check(nttProject(main, library)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if library.Symbols.Globals["value"].resolution != globalResolved || + main.Symbols.Globals["copy"].resolution != globalResolved { + t.Fatal("expected deferred unknown types to remain resolved dependencies") + } + if !main.Symbols.Globals["copy"].Type.IsUnknown() { + t.Fatalf("expected deferred type to stay unknown, got %s", main.Symbols.Globals["copy"].Type) + } +} + +func TestCheckNegativeStaticGlobalIndexVisibilityIsOrderIndependent(t *testing.T) { + for _, reverseDeclarations := range []bool{false, true} { + name := "declaration order" + if reverseDeclarations { + name = "reverse declaration order" + } + t.Run(name, func(t *testing.T) { + declarations := []ast.Declaration{ + indexedGlobal("stats", negativeIndex(nttInt(1, 1), 1), true, nttString("visible", 1), 1), + indexedGlobal("stats", negativeIndex(nttInt(2, 2), 2), false, nttString("hidden", 2), 2), + indexedGlobal("stats", negativeIndex(nttFloat(1.5, 3), 3), true, nttInt(15, 3), 3), + indexedGlobal("stats", negativeIndex(nttFloat(2.5, 4), 4), false, nttInt(25, 4), 4), + } + if reverseDeclarations { + for left, right := 0, len(declarations)-1; left < right; left, right = left+1, right-1 { + declarations[left], declarations[right] = declarations[right], declarations[left] + } + } + library := nttModule("lib/stats.puff", declarations...) + + publicInt := importedIndexedVariable("stats", "stats", negativeIndex(nttInt(1, 10), 10), 10) + privateInt := importedIndexedVariable("stats", "stats", negativeIndex(nttInt(2, 11), 11), 11) + publicFloat := importedIndexedVariable("stats", "stats", negativeIndex(nttFloat(1.5, 12), 12), 12) + privateFloat := importedIndexedVariable("stats", "stats", negativeIndex(nttFloat(2.5, 13), 13), 13) + main := nttModule("main.puff", nttEvent("load", + nttExprStmt(publicInt, 10), + nttExprStmt(privateInt, 11), + nttExprStmt(publicFloat, 12), + nttExprStmt(privateFloat, 13), + )) + main.Imports["stats"] = &Import{Prefix: "stats", Target: library} + + result := Check(nttProject(main, library)) + + if len(result.Diagnostics) != 2 { + t.Fatalf("expected exactly two private-index diagnostics, got %#v", result.Diagnostics) + } + assertDiagnosticCount(t, result.Diagnostics, diagnostic.CodeUndefinedVariable, 2) + assertExpressionKind(t, main, publicInt, TypeString) + assertExpressionKind(t, main, publicFloat, TypeInt) + if symbol := library.Symbols.lookupGlobal(publicInt); symbol == nil || !symbol.Public { + t.Fatalf("expected independent public negative int index, got %#v", symbol) + } + if symbol := library.Symbols.lookupGlobal(privateInt); symbol == nil || symbol.Public { + t.Fatalf("expected independent private negative int index, got %#v", symbol) + } + if symbol := library.Symbols.lookupGlobal(publicFloat); symbol == nil || !symbol.Public { + t.Fatalf("expected independent public negative float index, got %#v", symbol) + } + if symbol := library.Symbols.lookupGlobal(privateFloat); symbol == nil || symbol.Public { + t.Fatalf("expected independent private negative float index, got %#v", symbol) + } + }) + } +} + +func TestCheckTopLevelReassignmentUsesSequentialTypesAndKeepsLastType(t *testing.T) { + first := nttInt(1, 1) + read := nttVariable("x", false, 2) + sum := &ast.BinaryExpr{ + NodeBase: nttBase(2), + Left: read, + Operator: token.Plus, + Right: nttInt(1, 2), + } + last := nttString("done", 3) + module := nttModule("main.puff", + &ast.GlobalAssignment{NodeBase: nttBase(1), Target: nttVariable("x", false, 1), Value: first}, + &ast.GlobalAssignment{NodeBase: nttBase(2), Target: nttVariable("copy", false, 2), Value: sum}, + &ast.GlobalAssignment{NodeBase: nttBase(3), Target: nttVariable("x", false, 3), Value: last}, + ) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + assertExpressionKind(t, module, read, TypeInt) + assertExpressionKind(t, module, sum, TypeInt) + if got := module.Symbols.Globals["copy"].Type.Kind; got != TypeInt { + t.Fatalf("expected copy to observe the intermediate int type, got %s", got) + } + if got := module.Symbols.Globals["x"].Type.Kind; got != TypeString { + t.Fatalf("expected x to keep the last assignment type, got %s", got) + } +} + +func assertUndefinedGlobalDependency( + t *testing.T, + got diagnostic.Diagnostic, + file string, + name string, + node ast.Node, +) { + t.Helper() + if got.Code != diagnostic.CodeUndefinedVariable || + got.Phase != diagnostic.PhaseSemantics || + got.Severity != diagnostic.SeverityError || + got.Message != "Undefined variable: "+name || + got.Hint != "Declare it before using it: "+name+" = 0" || + got.File != file || + got.Span != node.Span() { + t.Fatalf("unexpected unresolved dependency diagnostic: %#v", got) + } +} + +func negativeIndex(operand ast.Expression, line int) *ast.UnaryExpr { + return &ast.UnaryExpr{ + NodeBase: nttBase(line), + Operator: token.Minus, + Operand: operand, + } +} diff --git a/src/internal/sema/nested_collection_test.go b/src/internal/sema/nested_collection_test.go new file mode 100644 index 0000000..8a18fec --- /dev/null +++ b/src/internal/sema/nested_collection_test.go @@ -0,0 +1,134 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" +) + +func TestCheckInfersNestedCollectionsAcrossEmptyPlaceholders(t *testing.T) { + t.Run("list", func(t *testing.T) { + value := nestedList( + &ast.ListExpr{NodeBase: nttBase(2)}, + nestedList(nttInt(1, 2)), + ) + function := returningFunction( + "values", + nttGenericType("list", 1, nttGenericType("list", 1, nttType("int", 1))), + value, + ) + module := nttModule("main.puff", function) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if got := module.ExpressionTypes[value].String(); got != "list>" { + t.Fatalf("expected list>, got %s", got) + } + }) + + t.Run("map values", func(t *testing.T) { + value := &ast.MapExpr{ + NodeBase: nttBase(2), + Entries: []ast.MapEntry{ + {Key: nttString("empty", 2), Value: &ast.ListExpr{NodeBase: nttBase(2)}}, + {Key: nttString("values", 2), Value: nestedList(nttInt(1, 2))}, + }, + } + function := returningFunction( + "values", + nttGenericType( + "map", + 1, + nttType("string", 1), + nttGenericType("list", 1, nttType("int", 1)), + ), + value, + ) + module := nttModule("main.puff", function) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if got := module.ExpressionTypes[value].String(); got != "map>" { + t.Fatalf("expected map>, got %s", got) + } + }) +} + +func TestCheckRejectsNestedCollectionTypeMismatchAfterEmptyPlaceholder(t *testing.T) { + value := nestedList( + &ast.ListExpr{NodeBase: nttBase(2)}, + nestedList(nttInt(1, 2)), + ) + function := returningFunction( + "values", + nttGenericType("list", 1, nttGenericType("list", 1, nttType("string", 1))), + value, + ) + + result := Check(nttProject(nttModule("main.puff", function))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot return list> as list>.", + Hint: "Return a value compatible with list>.", + File: "main.puff", + Span: value.Span(), + }) +} + +func TestCheckSuppressesNestedCollectionCascadeAfterUndefinedVariable(t *testing.T) { + missing := nttVariable("missing", false, 2) + value := nestedList( + &ast.ListExpr{NodeBase: nttBase(2)}, + nestedList(missing), + ) + function := returningFunction( + "values", + nttGenericType("list", 1, nttGenericType("list", 1, nttType("string", 1))), + value, + ) + module := nttModule("main.puff", function) + + result := Check(nttProject(module)) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: $missing", + Hint: "Declare it before using it: $missing = 0", + File: "main.puff", + Span: missing.Span(), + }) + if got := module.ExpressionTypes[value].String(); got != "list>" { + t.Fatalf("expected list>, got %s", got) + } +} + +func TestCheckPropagatesKnownNestedCollectionIncompatibility(t *testing.T) { + value := nestedList( + &ast.ListExpr{NodeBase: nttBase(2)}, + nestedList(nttInt(1, 2)), + nestedList(nttString("wrong", 2)), + ) + function := returningFunction( + "values", + nttGenericType("list", 1, nttGenericType("list", 1, nttType("int", 1))), + value, + ) + + result := Check(nttProject(nttModule("main.puff", function))) + + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != diagnostic.CodeTypeMismatch { + t.Fatalf("expected one TYPE_MISMATCH, got %#v", result.Diagnostics) + } +} + +func nestedList(elements ...ast.Expression) *ast.ListExpr { + return &ast.ListExpr{NodeBase: nttBase(2), Elements: elements} +} diff --git a/src/internal/sema/testdata/checker/closure-edges/puff.toml b/src/internal/sema/testdata/checker/closure-edges/puff.toml new file mode 100644 index 0000000..c5c3437 --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "checker-closure-edges" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/checker/closure-edges/src/a_cycle_no_seed.puff b/src/internal/sema/testdata/checker/closure-edges/src/a_cycle_no_seed.puff new file mode 100644 index 0000000..0697ec1 --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/a_cycle_no_seed.puff @@ -0,0 +1,7 @@ +require "b_cycle_no_seed" as cycle_b + +pub $x = cycle_b.$y + +fun cycleValue -> int + return $x +end diff --git a/src/internal/sema/testdata/checker/closure-edges/src/b_cycle_no_seed.puff b/src/internal/sema/testdata/checker/closure-edges/src/b_cycle_no_seed.puff new file mode 100644 index 0000000..0de769b --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/b_cycle_no_seed.puff @@ -0,0 +1,3 @@ +require "a_cycle_no_seed" as cycle_a + +pub $y = cycle_a.$x diff --git a/src/internal/sema/testdata/checker/closure-edges/src/c_negative_private_last.puff b/src/internal/sema/testdata/checker/closure-edges/src/c_negative_private_last.puff new file mode 100644 index 0000000..c33fdc4 --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/c_negative_private_last.puff @@ -0,0 +1,2 @@ +pub $stats[-1] = "visible" +$stats[-2] = "hidden" diff --git a/src/internal/sema/testdata/checker/closure-edges/src/d_negative_private_last_use.puff b/src/internal/sema/testdata/checker/closure-edges/src/d_negative_private_last_use.puff new file mode 100644 index 0000000..1e641b4 --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/d_negative_private_last_use.puff @@ -0,0 +1,4 @@ +require "c_negative_private_last" as private_last + +$public_value = private_last.$stats[-1] +$private_value = private_last.$stats[-2] diff --git a/src/internal/sema/testdata/checker/closure-edges/src/e_negative_public_last.puff b/src/internal/sema/testdata/checker/closure-edges/src/e_negative_public_last.puff new file mode 100644 index 0000000..b2c4a2b --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/e_negative_public_last.puff @@ -0,0 +1,2 @@ +$stats[-2] = "hidden" +pub $stats[-1] = "visible" diff --git a/src/internal/sema/testdata/checker/closure-edges/src/f_negative_public_last_use.puff b/src/internal/sema/testdata/checker/closure-edges/src/f_negative_public_last_use.puff new file mode 100644 index 0000000..a128865 --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/f_negative_public_last_use.puff @@ -0,0 +1,4 @@ +require "e_negative_public_last" as public_last + +$public_value = public_last.$stats[-1] +$private_value = public_last.$stats[-2] diff --git a/src/internal/sema/testdata/checker/closure-edges/src/g_nested_collection.puff b/src/internal/sema/testdata/checker/closure-edges/src/g_nested_collection.puff new file mode 100644 index 0000000..c4fdcee --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/g_nested_collection.puff @@ -0,0 +1,3 @@ +fun nestedValues -> list> + return [[], [1]] +end diff --git a/src/internal/sema/testdata/checker/closure-edges/src/h_top_level_reassignment.puff b/src/internal/sema/testdata/checker/closure-edges/src/h_top_level_reassignment.puff new file mode 100644 index 0000000..69c6877 --- /dev/null +++ b/src/internal/sema/testdata/checker/closure-edges/src/h_top_level_reassignment.puff @@ -0,0 +1,7 @@ +$value = 1 +$copy = $value + 1 +$value = "done" + +fun sentinel -> int + return "wrong" +end From 4c7e8c0d81822ffcc1a493efa8f759a000c0ab97 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 20:13:01 -0300 Subject: [PATCH 11/12] fix(sema): enforce runtime global flow --- src/internal/sema/checker.go | 8 ++++++ src/internal/sema/expressions.go | 38 ++++++++++++++++++++++--- src/internal/sema/statements.go | 48 ++++++++++++++++++++++++++++--- src/internal/sema/symbols.go | 49 ++++++++++++++++++++++++++++---- 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/src/internal/sema/checker.go b/src/internal/sema/checker.go index a058f8e..c55e4da 100644 --- a/src/internal/sema/checker.go +++ b/src/internal/sema/checker.go @@ -108,6 +108,9 @@ func (checker *checker) indexGlobal(module *Module, declaration *ast.GlobalAssig } path, depth := globalPath(target) + if depth < len(target.Accesses) && !endsWithEmptyIndex(target) { + return + } module.Symbols.Globals[path] = &VariableSymbol{ Name: target.Name.Name, Declaration: declaration, @@ -249,6 +252,11 @@ func (checker *checker) checkGlobalInitializers(module *Module, updateTypes bool if global.Target == nil || global.Target.Local { continue } + _, depth := globalPath(global.Target) + if depth < len(global.Target.Accesses) && !endsWithEmptyIndex(global.Target) { + checker.checkVariable(module, nil, global.Target) + continue + } if endsWithEmptyIndex(global.Target) && !typ.IsUnknown() && typ.Kind != TypeList { checker.typeMismatch(module, global.Value, fmt.Sprintf("Type mismatch: cannot assign %s to %s[].", typ.String(), variableName(global.Target))) diff --git a/src/internal/sema/expressions.go b/src/internal/sema/expressions.go index 1cfdb16..44652a3 100644 --- a/src/internal/sema/expressions.go +++ b/src/internal/sema/expressions.go @@ -88,6 +88,12 @@ func (checker *checker) checkString(module *Module, currentScope *scope, express func (checker *checker) checkUnary(module *Module, currentScope *scope, expression *ast.UnaryExpr) Type { operand := checker.checkExpression(module, currentScope, expression.Operand) + if operand.incompatible { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot use %s with incompatible values.", + operatorText(expression.Operator))) + return Type{Kind: TypeUnknown, incompatible: true} + } switch expression.Operator { case token.Not: if !operand.IsUnknown() && operand.Kind != TypeBool { @@ -116,6 +122,10 @@ func (checker *checker) checkBinary(module *Module, currentScope *scope, express switch expression.Operator { case token.Plus, token.Minus, token.Star, token.Slash, token.Percent: + if left.incompatible || right.incompatible { + checker.typeMismatch(module, expression, arithmeticMismatch(expression.Operator, left, right)) + return Type{Kind: TypeUnknown, incompatible: true} + } if expression.Operator == token.Plus && left.Kind == TypeString && right.Kind == TypeString { return Type{Kind: TypeString} } @@ -125,6 +135,12 @@ func (checker *checker) checkBinary(module *Module, currentScope *scope, express } return result case token.And, token.Or: + if left.incompatible || right.incompatible { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot use %s with %s and %s.", + operatorText(expression.Operator), left.String(), right.String())) + return Type{Kind: TypeBool} + } if (!left.IsUnknown() && left.Kind != TypeBool) || (!right.IsUnknown() && right.Kind != TypeBool) { checker.typeMismatch(module, expression, fmt.Sprintf("Type mismatch: cannot use %s with %s and %s.", @@ -132,6 +148,11 @@ func (checker *checker) checkBinary(module *Module, currentScope *scope, express } return Type{Kind: TypeBool} case token.EqualEqual, token.BangEqual: + if left.incompatible || right.incompatible { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot compare %s and %s.", left.String(), right.String())) + return Type{Kind: TypeBool} + } if !left.IsUnknown() && !right.IsUnknown() && !compatible(left, right) && !compatible(right, left) { checker.typeMismatch(module, expression, @@ -139,6 +160,11 @@ func (checker *checker) checkBinary(module *Module, currentScope *scope, express } return Type{Kind: TypeBool} case token.Greater, token.GreaterEq, token.Less, token.LessEq: + if left.incompatible || right.incompatible { + checker.typeMismatch(module, expression, + fmt.Sprintf("Type mismatch: cannot compare %s and %s.", left.String(), right.String())) + return Type{Kind: TypeBool} + } if !left.IsUnknown() && !right.IsUnknown() && numericType(left, right).IsUnknown() { checker.typeMismatch(module, expression, fmt.Sprintf("Type mismatch: cannot compare %s and %s.", left.String(), right.String())) @@ -281,8 +307,11 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia symbol, _ = currentScope.lookupLocal(variable.Name.Name) } else if typ, ok := currentScope.lookupName(variable.Name.Name); ok { return checker.typeAfterAccesses(typ, variable.Accesses) - } else if module != nil && module.Symbols != nil { - symbol = module.Symbols.lookupGlobal(variable) + } else { + symbol, _ = currentScope.lookupRuntimeGlobal(variable) + if symbol == nil && module != nil && module.Symbols != nil { + symbol = module.Symbols.lookupGlobal(variable) + } } if symbol == nil { @@ -293,15 +322,16 @@ func (checker *checker) checkVariable(module *Module, currentScope *scope, varia } if _, isGlobalDeclaration := symbol.Declaration.(*ast.GlobalAssignment); isGlobalDeclaration && (symbol.Module == module && !symbol.initialized || symbol.resolution == globalUnresolved) { + localBeforeDefinition := symbol.Module == module && !symbol.initialized if checker.unresolvedGlobal != nil { checker.unresolvedGlobal.unresolved = true checker.unresolvedGlobal.reported = symbol.reported } - if !symbol.reported { + if localBeforeDefinition || !symbol.reported { checker.report(module, variable, diagnostic.CodeUndefinedVariable, fmt.Sprintf("Undefined variable: %s", variableName(variable)), fmt.Sprintf("Declare it before using it: %s = 0", variableName(variable))) - if !checker.suppressDiagnostics { + if !localBeforeDefinition && !checker.suppressDiagnostics { symbol.reported = true if checker.unresolvedGlobal != nil { checker.unresolvedGlobal.reported = true diff --git a/src/internal/sema/statements.go b/src/internal/sema/statements.go index 7123264..3383542 100644 --- a/src/internal/sema/statements.go +++ b/src/internal/sema/statements.go @@ -14,9 +14,10 @@ type flowContext struct { func isolatedFlowScope(parent *scope) *scope { current := &scope{ - parent: parent, - names: make(map[string]Type), - locals: copyLocals(parent), + parent: parent, + names: make(map[string]Type), + locals: copyLocals(parent), + runtimeGlobals: copyRuntimeGlobals(parent), } current.owner = current return current @@ -37,6 +38,21 @@ func copyLocals(current *scope) map[string]*VariableSymbol { return copied } +func copyRuntimeGlobals(current *scope) map[string]*VariableSymbol { + copied := make(map[string]*VariableSymbol) + if current == nil { + return copied + } + owner := current.owner + if owner == nil { + owner = current + } + for path, symbol := range owner.runtimeGlobals { + copied[path] = symbol + } + return copied +} + func mergeFlowScopes(target *scope, paths []*scope) { if target == nil || len(paths) == 0 { return @@ -63,6 +79,23 @@ func mergeFlowScopes(target *scope, paths []*scope) { owner = target } owner.locals = merged + + mergedGlobals := copyRuntimeGlobals(paths[0]) + for path, first := range mergedGlobals { + combined := *first + for _, branch := range paths[1:] { + candidate, ok := copyRuntimeGlobals(branch)[path] + if !ok { + delete(mergedGlobals, path) + break + } + combined.Type = mergeInferredTypes(combined.Type, candidate.Type) + } + if _, ok := mergedGlobals[path]; ok { + mergedGlobals[path] = &combined + } + } + owner.runtimeGlobals = mergedGlobals } func (checker *checker) checkBlock( @@ -184,13 +217,20 @@ func (checker *checker) checkAssignment( path, depth := globalPath(target) symbol := module.Symbols.lookupGlobal(target) if symbol == nil { + symbol, _ = currentScope.lookupRuntimeGlobal(target) + } + if symbol == nil { + if depth < len(target.Accesses) && !endsWithEmptyIndex(target) { + checker.undefinedVariable(module, target) + return + } symbol = &VariableSymbol{ Name: target.Name.Name, Declaration: statement, Module: module, AccessDepth: depth, } - module.Symbols.Globals[path] = symbol + currentScope.defineRuntimeGlobal(path, symbol) } if symbol.AccessDepth == len(target.Accesses) || endsWithEmptyIndex(target) && symbol.AccessDepth == len(target.Accesses)-1 { diff --git a/src/internal/sema/symbols.go b/src/internal/sema/symbols.go index 103e79b..b9e5143 100644 --- a/src/internal/sema/symbols.go +++ b/src/internal/sema/symbols.go @@ -140,16 +140,18 @@ func staticIndexValue(expression ast.Expression) (string, bool) { } type scope struct { - parent *scope - owner *scope - names map[string]Type - locals map[string]*VariableSymbol + parent *scope + owner *scope + names map[string]Type + locals map[string]*VariableSymbol + runtimeGlobals map[string]*VariableSymbol } func newExecutionScope() *scope { current := &scope{ - names: make(map[string]Type), - locals: make(map[string]*VariableSymbol), + names: make(map[string]Type), + locals: make(map[string]*VariableSymbol), + runtimeGlobals: make(map[string]*VariableSymbol), } current.owner = current return current @@ -203,3 +205,38 @@ func (current *scope) lookupLocal(name string) (*VariableSymbol, bool) { symbol, ok := owner.locals[name] return symbol, ok } + +func (current *scope) defineRuntimeGlobal(path string, symbol *VariableSymbol) { + if current == nil || path == "" || symbol == nil { + return + } + owner := current.owner + if owner == nil { + owner = current + } + owner.runtimeGlobals[path] = symbol +} + +func (current *scope) lookupRuntimeGlobal(variable *ast.VariableExpr) (*VariableSymbol, bool) { + if current == nil || variable == nil { + return nil, false + } + owner := current.owner + if owner == nil { + owner = current + } + + path := variable.Name.Name + symbol := owner.runtimeGlobals[path] + for _, access := range variable.Accesses { + part, ok := staticGlobalAccess(access) + if !ok { + break + } + path += part + if candidate := owner.runtimeGlobals[path]; candidate != nil { + symbol = candidate + } + } + return symbol, symbol != nil +} From e786eb1952e7bf030be55572094545e7c1552c25 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Wed, 29 Jul 2026 20:13:01 -0300 Subject: [PATCH 12/12] test(sema): cover runtime global flow --- .../sema/final_review_regression_test.go | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/internal/sema/final_review_regression_test.go diff --git a/src/internal/sema/final_review_regression_test.go b/src/internal/sema/final_review_regression_test.go new file mode 100644 index 0000000..d8b281e --- /dev/null +++ b/src/internal/sema/final_review_regression_test.go @@ -0,0 +1,161 @@ +package sema + +import ( + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/token" +) + +func TestCheckArithmeticRejectsIncompatibleBranchType(t *testing.T) { + value := nttVariable("value", true, 8) + sum := &ast.BinaryExpr{ + NodeBase: nttBase(8), + Left: value, + Operator: token.Plus, + Right: nttInt(1, 8), + } + branch := &ast.IfStmt{ + NodeBase: nttBase(3), + Condition: &ast.BoolLiteral{NodeBase: nttBase(3), Value: true}, + Then: ast.Block{Statements: []ast.Statement{ + localAssignment("value", nttInt(1, 4), 4), + }}, + Else: &ast.Block{Statements: []ast.Statement{ + localAssignment("value", nttString("wrong", 6), 6), + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + branch, + nttExprStmt(sum, 8), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeTypeMismatch, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Type mismatch: cannot add unknown and int.", + Hint: "Convert one value or use compatible types.", + File: "main.puff", + Span: sum.Span(), + }) +} + +func TestCheckDynamicTopLevelIndexDoesNotRedefineRoot(t *testing.T) { + root := nttVariable("stats", false, 1) + key := nttVariable("key", false, 2) + dynamicTarget := indexedVariable("stats", key, 3) + module := nttModule("main.puff", + &ast.GlobalAssignment{ + NodeBase: nttBase(1), + Public: true, + Target: root, + Value: &ast.MapExpr{NodeBase: nttBase(1), Entries: []ast.MapEntry{{ + Key: nttString("coins", 1), Value: nttInt(1, 1), + }}}, + }, + &ast.GlobalAssignment{ + NodeBase: nttBase(2), + Target: nttVariable("key", false, 2), + Value: nttString("coins", 2), + }, + &ast.GlobalAssignment{ + NodeBase: nttBase(3), + Target: dynamicTarget, + Value: nttInt(2, 3), + }, + ) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + symbol := module.Symbols.Globals["stats"] + if symbol == nil || !symbol.Public || symbol.Type.Kind != TypeMap { + t.Fatalf("expected public map root to remain intact, got %#v", symbol) + } + if module.ResolvedVariables[dynamicTarget] != symbol { + t.Fatalf("expected dynamic assignment to resolve the root symbol") + } +} + +func TestCheckConditionalRuntimeGlobalRequiresDefinitionOnEveryPath(t *testing.T) { + read := nttVariable("coins", false, 6) + branch := &ast.IfStmt{ + NodeBase: nttBase(2), + Condition: &ast.BoolLiteral{NodeBase: nttBase(2), Value: true}, + Then: ast.Block{Statements: []ast.Statement{ + &ast.AssignmentStmt{ + NodeBase: nttBase(3), + Target: nttVariable("coins", false, 3), + Value: nttInt(1, 3), + }, + }}, + } + + result := Check(nttProject(nttModule("main.puff", nttEvent("load", + branch, + nttExprStmt(read, 6), + )))) + + nttAssertDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeUndefinedVariable, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Undefined variable: $coins", + Hint: "Declare it before using it: $coins = 0", + File: "main.puff", + Span: read.Span(), + }) +} + +func TestCheckConditionalRuntimeGlobalMergesAllPaths(t *testing.T) { + read := nttVariable("coins", false, 7) + branch := &ast.IfStmt{ + NodeBase: nttBase(2), + Condition: &ast.BoolLiteral{NodeBase: nttBase(2), Value: true}, + Then: ast.Block{Statements: []ast.Statement{ + &ast.AssignmentStmt{ + NodeBase: nttBase(3), + Target: nttVariable("coins", false, 3), + Value: nttInt(1, 3), + }, + }}, + Else: &ast.Block{Statements: []ast.Statement{ + &ast.AssignmentStmt{ + NodeBase: nttBase(5), + Target: nttVariable("coins", false, 5), + Value: nttFloat(2.5, 5), + }, + }}, + } + module := nttModule("main.puff", nttEvent("load", branch, nttExprStmt(read, 7))) + + result := Check(nttProject(module)) + + nttAssertNoDiagnostics(t, result.Diagnostics) + if typ := module.ExpressionTypes[read]; typ.Kind != TypeFloat { + t.Fatalf("expected runtime global paths to merge to float, got %s", typ.String()) + } +} + +func TestCheckReportsEachIndependentReadBeforeDefinition(t *testing.T) { + first := nttVariable("later", false, 1) + second := nttVariable("later", false, 2) + module := nttModule("main.puff", + &ast.GlobalAssignment{NodeBase: nttBase(1), Target: nttVariable("a", false, 1), Value: first}, + &ast.GlobalAssignment{NodeBase: nttBase(2), Target: nttVariable("b", false, 2), Value: second}, + &ast.GlobalAssignment{NodeBase: nttBase(3), Target: nttVariable("later", false, 3), Value: nttInt(1, 3)}, + ) + + result := Check(nttProject(module)) + + if len(result.Diagnostics) != 2 { + t.Fatalf("expected one diagnostic per invalid read, got %#v", result.Diagnostics) + } + assertDiagnosticCount(t, result.Diagnostics, diagnostic.CodeUndefinedVariable, 2) + if result.Diagnostics[0].Span != first.Span() || result.Diagnostics[1].Span != second.Span() { + t.Fatalf("expected diagnostics at both reads, got %#v", result.Diagnostics) + } +}