diff --git a/src/internal/sema/module.go b/src/internal/sema/module.go new file mode 100644 index 0000000..06048fc --- /dev/null +++ b/src/internal/sema/module.go @@ -0,0 +1,53 @@ +package sema + +import ( + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/source" +) + +type Import struct { + Declaration *ast.RequireDecl + Path string + Prefix string + Target *Module +} + +type Module struct { + Source source.File + Syntax *ast.File + Imports map[string]*Import +} + +func (module *Module) Import(prefix string) (*Import, bool) { + if module == nil { + return nil, false + } + + resolved, ok := module.Imports[prefix] + return resolved, ok +} + +type Project struct { + Root string + Modules []*Module +} + +func (project *Project) Module(relPath string) (*Module, bool) { + if project == nil { + return nil, false + } + + for _, module := range project.Modules { + if module.Source.RelPath == relPath { + return module, true + } + } + + return nil, false +} + +type Result struct { + Project *Project + Diagnostics []diagnostic.Diagnostic +} diff --git a/src/internal/sema/resolver.go b/src/internal/sema/resolver.go new file mode 100644 index 0000000..5984792 --- /dev/null +++ b/src/internal/sema/resolver.go @@ -0,0 +1,223 @@ +package sema + +import ( + "fmt" + "path" + "path/filepath" + "strings" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/source" +) + +func Resolve(sourceProject source.Project, syntax map[string]*ast.File) Result { + project := &Project{ + Root: sourceProject.Root, + Modules: make([]*Module, 0, len(sourceProject.Files)), + } + modules := make(map[string]*Module, len(sourceProject.Files)) + + for _, file := range sourceProject.Files { + module := &Module{ + Source: file, + Syntax: syntax[file.RelPath], + Imports: make(map[string]*Import), + } + project.Modules = append(project.Modules, module) + modules[file.RelPath] = module + } + + var diagnostics []diagnostic.Diagnostic + for _, module := range project.Modules { + if module.Syntax == nil { + continue + } + + for _, declaration := range module.Syntax.Requirements { + importPath, ok := staticImportPath(declaration) + if ok { + importPath, ok = canonicalImportPath(importPath) + } + if !ok { + diagnostics = append(diagnostics, importDiagnostic( + module, + declaration, + diagnostic.CodeInvalidImport, + "Invalid import path.", + "", + )) + continue + } + + prefix := path.Base(importPath) + if declaration.Alias != nil { + prefix = declaration.Alias.Name + } else if !validPrefix(prefix) { + diagnostics = append(diagnostics, importDiagnostic( + module, + declaration, + diagnostic.CodeInvalidImportPrefix, + fmt.Sprintf("Import path ends with %q, which is not a valid prefix.", prefix), + fmt.Sprintf("Use an alias: require %q as %s", importPath, suggestedAlias(prefix)), + )) + continue + } + + fileCandidate := importPath + ".puff" + directoryCandidate := path.Join(importPath, "main.puff") + fileModule, hasFile := modules[fileCandidate] + directoryModule, hasDirectory := modules[directoryCandidate] + + if hasFile && hasDirectory { + diagnostics = append(diagnostics, importDiagnostic( + module, + declaration, + diagnostic.CodeAmbiguousImport, + fmt.Sprintf( + "Ambiguous import: both %s and %s exist.", + displayPath(sourceProject.Root, fileModule.Source), + displayPath(sourceProject.Root, directoryModule.Source), + ), + "Remove one file or import a more specific path.", + )) + continue + } + + target := fileModule + if !hasFile { + target = directoryModule + } + if target == nil { + diagnostics = append(diagnostics, importDiagnostic( + module, + declaration, + diagnostic.CodeImportNotFound, + fmt.Sprintf("Import not found: %s", importPath), + "Check the path or install the dependency.", + )) + continue + } + + module.Imports[prefix] = &Import{ + Declaration: declaration, + Path: importPath, + Prefix: prefix, + Target: target, + } + } + } + + return Result{ + Project: project, + Diagnostics: diagnostics, + } +} + +func staticImportPath(declaration *ast.RequireDecl) (string, bool) { + if declaration == nil || declaration.Path == nil { + return "", false + } + + var builder strings.Builder + for _, part := range declaration.Path.Parts { + text, ok := part.(*ast.StringText) + if !ok { + return "", false + } + builder.WriteString(text.Value) + } + + importPath := builder.String() + return importPath, importPath != "" +} + +func canonicalImportPath(importPath string) (string, bool) { + canonical := path.Clean(importPath) + if canonical != importPath || + path.IsAbs(canonical) || + windowsDriveAbsolute(canonical) || + canonical == "." || + canonical == ".." || + strings.HasPrefix(canonical, "../") || + strings.Contains(canonical, `\`) { + return "", false + } + return canonical, true +} + +func windowsDriveAbsolute(importPath string) bool { + return len(importPath) >= 3 && + asciiLetter(importPath[0]) && + importPath[1] == ':' && + importPath[2] == '/' +} + +func validPrefix(prefix string) bool { + for index, char := range []byte(prefix) { + if index == 0 { + if !asciiLetter(char) { + return false + } + continue + } + if !asciiLetter(char) && (char < '0' || char > '9') && char != '_' { + return false + } + } + return prefix != "" +} + +func asciiLetter(char byte) bool { + return char >= 'A' && char <= 'Z' || char >= 'a' && char <= 'z' +} + +func suggestedAlias(segment string) string { + var builder strings.Builder + builder.WriteString("lib") + for _, char := range []byte(segment) { + if asciiLetter(char) || char >= '0' && char <= '9' || char == '_' { + builder.WriteByte(char) + } + } + return builder.String() +} + +func importDiagnostic( + module *Module, + declaration *ast.RequireDecl, + code diagnostic.Code, + message string, + hint string, +) diagnostic.Diagnostic { + var span diagnostic.Span + if declaration != nil { + span = declaration.Span() + if declaration.Path != nil { + span = declaration.Path.Span() + } + } + + return diagnostic.Diagnostic{ + Code: code, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: message, + Hint: hint, + File: module.Source.RelPath, + Span: span, + } +} + +func displayPath(root string, file source.File) string { + if root != "" && file.Path != "" { + relative, err := filepath.Rel(root, file.Path) + if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return filepath.ToSlash(relative) + } + } + if root == "" && file.Path != "" && !filepath.IsAbs(file.Path) { + return filepath.ToSlash(file.Path) + } + return file.RelPath +} diff --git a/src/internal/sema/resolver_integration_test.go b/src/internal/sema/resolver_integration_test.go new file mode 100644 index 0000000..f5390e3 --- /dev/null +++ b/src/internal/sema/resolver_integration_test.go @@ -0,0 +1,269 @@ +package sema + +import ( + "path/filepath" + "reflect" + "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 TestResolveIntegrationSelectsLocalModules(t *testing.T) { + tests := []struct { + name string + fixture string + importPath string + prefix string + targetPath string + }{ + {name: "direct file", fixture: "direct-file", importPath: "abc/shop", prefix: "shop", targetPath: "abc/shop.puff"}, + {name: "directory module", fixture: "directory-module", importPath: "abc/shop", prefix: "shop", targetPath: "abc/shop/main.puff"}, + {name: "explicit alias", fixture: "alias", importPath: "abc/shop", prefix: "economy", targetPath: "abc/shop.puff"}, + {name: "alias repairs inferred prefix", fixture: "invalid-prefix-aliased", importPath: "github.com/123/123", prefix: "lib123", targetPath: "github.com/123/123.puff"}, + {name: "custom source directory", fixture: "custom-source", importPath: "abc/shop", prefix: "shop", targetPath: "abc/shop.puff"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + loaded, result := resolveFixture(t, test.fixture) + if len(result.Diagnostics) != 0 { + t.Fatalf("expected no diagnostics, got %#v", result.Diagnostics) + } + assertModuleOrder(t, loaded, result.Project) + + main := requireResolvedModule(t, result.Project, "main.puff") + resolved, ok := main.Import(test.prefix) + if !ok { + t.Fatalf("expected import prefix %q, got %#v", test.prefix, main.Imports) + } + if resolved.Path != test.importPath { + t.Errorf("expected import path %q, got %q", test.importPath, resolved.Path) + } + if resolved.Prefix != test.prefix { + t.Errorf("expected prefix %q, got %q", test.prefix, resolved.Prefix) + } + if resolved.Declaration == nil { + t.Fatal("expected import declaration") + } + if resolved.Target == nil || resolved.Target.Source.RelPath != test.targetPath { + t.Fatalf("expected target %q, got %#v", test.targetPath, resolved.Target) + } + indexedTarget := requireResolvedModule(t, result.Project, test.targetPath) + if resolved.Target != indexedTarget { + t.Error("expected import target to reference the indexed project module") + } + }) + } +} + +func TestResolveIntegrationRejectsFailedImports(t *testing.T) { + tests := []struct { + name string + fixture string + code diagnostic.Code + message string + hint string + }{ + { + name: "ambiguous candidates", + fixture: "ambiguous", + code: diagnostic.CodeAmbiguousImport, + message: "Ambiguous import: both src/abc/shop.puff and src/abc/shop/main.puff exist.", + hint: "Remove one file or import a more specific path.", + }, + { + name: "invalid inferred prefix", + fixture: "invalid-prefix", + code: diagnostic.CodeInvalidImportPrefix, + message: `Import path ends with "123", which is not a valid prefix.`, + hint: `Use an alias: require "github.com/123/123" as lib123`, + }, + { + name: "missing candidates", + fixture: "missing", + code: diagnostic.CodeImportNotFound, + message: "Import not found: abc/shop", + hint: "Check the path or install the dependency.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, result := resolveFixture(t, test.fixture) + if len(result.Diagnostics) != 1 { + t.Fatalf("expected one diagnostic, got %#v", result.Diagnostics) + } + + main := requireResolvedModule(t, result.Project, "main.puff") + if len(main.Imports) != 0 { + t.Fatalf("expected no failed import bindings, got %#v", main.Imports) + } + assertImportDiagnostic(t, result.Diagnostics[0], test.code, test.message, test.hint, main) + }) + } +} + +func TestResolveIntegrationRejectsNonStaticAndEmptyPaths(t *testing.T) { + _, result := resolveFixture(t, "invalid-import") + if len(result.Diagnostics) != 2 { + t.Fatalf("expected two diagnostics, got %#v", result.Diagnostics) + } + + main := requireResolvedModule(t, result.Project, "main.puff") + if len(main.Imports) != 0 { + t.Fatalf("expected no invalid import bindings, got %#v", main.Imports) + } + for index, got := range result.Diagnostics { + assertImportDiagnostic(t, got, diagnostic.CodeInvalidImport, "Invalid import path.", "", main) + if got.Span != main.Syntax.Requirements[index].Path.Span() { + t.Errorf("diagnostic %d: expected path span %#v, got %#v", index, main.Syntax.Requirements[index].Path.Span(), got.Span) + } + } +} + +func TestResolveIntegrationKeepsImportedSymbolsNamespaced(t *testing.T) { + _, result := resolveFixture(t, "prefix-required") + if len(result.Diagnostics) != 0 { + t.Fatalf("expected no diagnostics, got %#v", result.Diagnostics) + } + + main := requireResolvedModule(t, result.Project, "main.puff") + if len(main.Imports) != 1 { + t.Fatalf("expected only the module prefix binding, got %#v", main.Imports) + } + if _, ok := main.Import("finalPrice"); ok { + t.Error("imported function must not be injected into the importer") + } + if _, ok := main.Import("tax"); ok { + t.Error("imported variable must not be injected into the importer") + } + + callAssignment := requireGlobalAssignment(t, main.Syntax.Declarations[0]) + call, ok := callAssignment.Value.(*ast.CallExpr) + if !ok { + t.Fatalf("expected qualified call, got %T", callAssignment.Value) + } + gotCallee := make([]string, len(call.Callee.Parts)) + for index, part := range call.Callee.Parts { + gotCallee[index] = part.Name + } + if want := []string{"shop", "finalPrice"}; !reflect.DeepEqual(gotCallee, want) { + t.Errorf("expected qualified callee %v, got %v", want, gotCallee) + } + + variableAssignment := requireGlobalAssignment(t, main.Syntax.Declarations[1]) + variable, ok := variableAssignment.Value.(*ast.VariableExpr) + if !ok { + t.Fatalf("expected qualified variable, got %T", variableAssignment.Value) + } + if variable.Qualifier == nil || variable.Qualifier.Name != "shop" || variable.Name.Name != "tax" { + t.Errorf("expected shop.$tax, got %#v", variable) + } + + imported, _ := main.Import("shop") + if len(imported.Target.Syntax.Declarations) != 2 { + t.Fatalf("expected imported declarations to remain on the target module, got %#v", imported.Target.Syntax.Declarations) + } +} + +func resolveFixture(t *testing.T, name string) (source.Project, Result) { + t.Helper() + + root := filepath.Join("testdata", 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 { + parsed := parser.Parse(file, lexer.Lex(file)) + if len(parsed.Diagnostics) != 0 { + t.Fatalf("parse %s: %#v", file.RelPath, parsed.Diagnostics) + } + syntax[file.RelPath] = parsed.File + } + + return loaded, Resolve(loaded, syntax) +} + +func assertModuleOrder(t *testing.T, loaded source.Project, resolved *Project) { + t.Helper() + + if resolved == nil { + t.Fatal("expected resolved project") + } + if resolved.Root != loaded.Root { + t.Errorf("expected root %q, got %q", loaded.Root, resolved.Root) + } + if len(resolved.Modules) != len(loaded.Files) { + t.Fatalf("expected %d modules, got %d", len(loaded.Files), len(resolved.Modules)) + } + for index, file := range loaded.Files { + if resolved.Modules[index].Source.RelPath != file.RelPath { + t.Errorf("module %d: expected %q, got %q", index, file.RelPath, resolved.Modules[index].Source.RelPath) + } + } +} + +func requireResolvedModule(t *testing.T, resolved *Project, relPath string) *Module { + t.Helper() + + module, ok := resolved.Module(relPath) + if !ok { + t.Fatalf("expected module %q", relPath) + } + return module +} + +func assertImportDiagnostic( + t *testing.T, + got diagnostic.Diagnostic, + code diagnostic.Code, + message string, + hint string, + importer *Module, +) { + t.Helper() + + if got.Code != code { + t.Errorf("expected code %s, got %s", code, got.Code) + } + if got.Phase != diagnostic.PhaseSemantics { + t.Errorf("expected semantics phase, got %s", got.Phase) + } + if got.Severity != diagnostic.SeverityError { + t.Errorf("expected error severity, got %s", got.Severity) + } + if got.File != importer.Source.RelPath { + t.Errorf("expected importer file %q, got %q", importer.Source.RelPath, got.File) + } + if got.Message != message { + t.Errorf("expected message %q, got %q", message, got.Message) + } + if got.Hint != hint { + t.Errorf("expected hint %q, got %q", hint, got.Hint) + } + if len(importer.Syntax.Requirements) == 1 && got.Span != importer.Syntax.Requirements[0].Path.Span() { + t.Errorf("expected path span %#v, got %#v", importer.Syntax.Requirements[0].Path.Span(), got.Span) + } +} + +func requireGlobalAssignment(t *testing.T, declaration ast.Declaration) *ast.GlobalAssignment { + t.Helper() + + assignment, ok := declaration.(*ast.GlobalAssignment) + if !ok { + t.Fatalf("expected global assignment, got %T", declaration) + } + return assignment +} diff --git a/src/internal/sema/resolver_test.go b/src/internal/sema/resolver_test.go new file mode 100644 index 0000000..20997c6 --- /dev/null +++ b/src/internal/sema/resolver_test.go @@ -0,0 +1,398 @@ +package sema + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/puff-lang/puff/internal/ast" + "github.com/puff-lang/puff/internal/diagnostic" + "github.com/puff-lang/puff/internal/source" +) + +func TestResolveLocalModuleCandidates(t *testing.T) { + tests := []struct { + name string + targetPath string + }{ + {name: "direct file", targetPath: "abc/shop.puff"}, + {name: "directory main", targetPath: "abc/shop/main.puff"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requirement := staticRequire("abc/shop", "") + input := testSourceProject("main.puff", test.targetPath) + syntax := testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + }) + + result := Resolve(input, syntax) + + assertNoDiagnostics(t, result.Diagnostics) + importer := requireTestModule(t, result.Project, "main.puff") + target := requireTestModule(t, result.Project, test.targetPath) + resolved, ok := importer.Import("shop") + if !ok { + t.Fatal("expected import to be bound under default prefix shop") + } + if resolved.Declaration != requirement || resolved.Path != "abc/shop" || + resolved.Prefix != "shop" || resolved.Target != target { + t.Fatalf("unexpected resolved import: %#v", resolved) + } + if len(importer.Imports) != 1 { + t.Fatalf("expected one prefix binding, got %#v", importer.Imports) + } + }) + } +} + +func TestResolveReportsAmbiguousAndMissingImports(t *testing.T) { + tests := []struct { + name string + files []string + code diagnostic.Code + message string + hint string + }{ + { + name: "ambiguous", + files: []string{"main.puff", "abc/shop.puff", "abc/shop/main.puff"}, + code: diagnostic.CodeAmbiguousImport, + message: "Ambiguous import: both src/abc/shop.puff and src/abc/shop/main.puff exist.", + hint: "Remove one file or import a more specific path.", + }, + { + name: "missing", + files: []string{"main.puff"}, + code: diagnostic.CodeImportNotFound, + message: "Import not found: abc/shop", + hint: "Check the path or install the dependency.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + requirement := staticRequire("abc/shop", "") + input := testSourceProject(test.files...) + syntax := testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + }) + + result := Resolve(input, syntax) + + assertSingleDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: test.code, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: test.message, + Hint: test.hint, + File: "main.puff", + Span: requirement.Path.Span(), + }) + importer := requireTestModule(t, result.Project, "main.puff") + if len(importer.Imports) != 0 { + t.Fatalf("failed import must not create a binding: %#v", importer.Imports) + } + }) + } +} + +func TestResolveImportPrefixesAndAliases(t *testing.T) { + t.Run("explicit alias overrides default prefix", func(t *testing.T) { + requirement := staticRequire("abc/shop", "economy") + input := testSourceProject("main.puff", "abc/shop.puff") + result := Resolve(input, testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + })) + + assertNoDiagnostics(t, result.Diagnostics) + importer := requireTestModule(t, result.Project, "main.puff") + resolved, ok := importer.Import("economy") + if !ok || resolved.Prefix != "economy" { + t.Fatalf("expected economy alias, got %#v", resolved) + } + if _, ok := importer.Import("shop"); ok { + t.Fatal("default prefix must not remain bound when an alias is present") + } + if len(importer.Imports) != 1 { + t.Fatalf("expected only the alias binding, got %#v", importer.Imports) + } + }) + + t.Run("invalid inferred prefix", func(t *testing.T) { + requirement := staticRequire("abc/123", "") + input := testSourceProject("main.puff", "abc/123.puff") + result := Resolve(input, testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + })) + + assertSingleDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeInvalidImportPrefix, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: `Import path ends with "123", which is not a valid prefix.`, + Hint: `Use an alias: require "abc/123" as lib123`, + File: "main.puff", + Span: requirement.Path.Span(), + }) + importer := requireTestModule(t, result.Project, "main.puff") + if len(importer.Imports) != 0 { + t.Fatalf("invalid prefix must not create a binding: %#v", importer.Imports) + } + }) + + t.Run("alias repairs numeric final segment", func(t *testing.T) { + requirement := staticRequire("abc/123", "lib123") + input := testSourceProject("main.puff", "abc/123.puff") + result := Resolve(input, testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + })) + + assertNoDiagnostics(t, result.Diagnostics) + importer := requireTestModule(t, result.Project, "main.puff") + resolved, ok := importer.Import("lib123") + if !ok || resolved.Path != "abc/123" || resolved.Prefix != "lib123" { + t.Fatalf("expected repaired prefix binding, got %#v", resolved) + } + if _, ok := importer.Import("123"); ok { + t.Fatal("numeric default prefix must not be bound") + } + }) +} + +func TestResolveRejectsInvalidImportPaths(t *testing.T) { + tests := []struct { + name string + requirement *ast.RequireDecl + }{ + {name: "empty", requirement: staticRequire("", "")}, + {name: "interpolated", requirement: interpolatedRequire()}, + {name: "non-canonical", requirement: staticRequire("./abc/shop", "")}, + {name: "escaping source root", requirement: staticRequire("../shop", "")}, + {name: "absolute", requirement: staticRequire("/abc/shop", "")}, + {name: "windows drive absolute", requirement: staticRequire("C:/abc/shop", "")}, + {name: "backslash", requirement: staticRequire(`abc\shop`, "")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := testSourceProject("main.puff") + result := Resolve(input, testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {test.requirement}, + })) + + assertSingleDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeInvalidImport, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Invalid import path.", + File: "main.puff", + Span: test.requirement.Path.Span(), + }) + importer := requireTestModule(t, result.Project, "main.puff") + if len(importer.Imports) != 0 { + t.Fatalf("invalid path must not create a binding: %#v", importer.Imports) + } + }) + } +} + +func TestResolveFormatsAmbiguityFromRelativeProjectRoot(t *testing.T) { + requirement := staticRequire("abc/shop", "") + input := testSourceProject("main.puff", "abc/shop.puff", "abc/shop/main.puff") + input.Root = "" + for index := range input.Files { + input.Files[index].Path = filepath.FromSlash("src/" + input.Files[index].RelPath) + } + + result := Resolve(input, testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + })) + + assertSingleDiagnostic(t, result.Diagnostics, diagnostic.Diagnostic{ + Code: diagnostic.CodeAmbiguousImport, + Phase: diagnostic.PhaseSemantics, + Severity: diagnostic.SeverityError, + Message: "Ambiguous import: both src/abc/shop.puff and src/abc/shop/main.puff exist.", + Hint: "Remove one file or import a more specific path.", + File: "main.puff", + Span: requirement.Path.Span(), + }) +} + +func TestResolvePreservesFileOrderAndModuleLookup(t *testing.T) { + input := testSourceProject("z.puff", "nested/main.puff", "a.puff") + result := Resolve(input, testSyntax(input, nil)) + + assertNoDiagnostics(t, result.Diagnostics) + got := make([]string, 0, len(result.Project.Modules)) + for _, module := range result.Project.Modules { + got = append(got, module.Source.RelPath) + } + want := []string{"z.puff", "nested/main.puff", "a.puff"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("module order changed: got %v, want %v", got, want) + } + if result.Project.Root != input.Root { + t.Fatalf("project root changed: got %q, want %q", result.Project.Root, input.Root) + } + for _, relPath := range want { + module, ok := result.Project.Module(relPath) + if !ok || module.Source.RelPath != relPath { + t.Fatalf("module lookup failed for %q: %#v", relPath, module) + } + } + if module, ok := result.Project.Module("missing.puff"); ok || module != nil { + t.Fatalf("missing module lookup returned %#v, %v", module, ok) + } +} + +func TestResolveKeepsImportedSymbolsNamespaced(t *testing.T) { + requirement := staticRequire("abc/shop", "") + input := testSourceProject("main.puff", "abc/shop.puff") + syntax := testSyntax(input, map[string][]*ast.RequireDecl{ + "main.puff": {requirement}, + }) + syntax["abc/shop.puff"].Declarations = []ast.Declaration{ + &ast.FunctionDecl{Public: true, Name: ast.Identifier{Name: "finalPrice"}}, + &ast.GlobalAssignment{ + Public: true, + Target: &ast.VariableExpr{Name: ast.Identifier{Name: "tax"}}, + }, + } + + result := Resolve(input, syntax) + + assertNoDiagnostics(t, result.Diagnostics) + importer := requireTestModule(t, result.Project, "main.puff") + if len(importer.Imports) != 1 { + t.Fatalf("expected one namespace binding, got %#v", importer.Imports) + } + if _, ok := importer.Import("shop"); !ok { + t.Fatal("expected imported symbols to be reachable through shop") + } + if _, ok := importer.Import("finalPrice"); ok { + t.Fatal("imported function must not be injected as an unqualified binding") + } + if _, ok := importer.Import("tax"); ok { + t.Fatal("imported variable must not be injected as an unqualified binding") + } + if len(importer.Syntax.Declarations) != 0 { + t.Fatalf("resolver injected declarations into importer syntax: %#v", importer.Syntax.Declarations) + } +} + +func testSourceProject(relPaths ...string) source.Project { + root := filepath.Join(string(filepath.Separator), "project") + files := make([]source.File, 0, len(relPaths)) + for _, relPath := range relPaths { + files = append(files, source.NewFile( + filepath.Join(root, "src", filepath.FromSlash(relPath)), + relPath, + "", + )) + } + return source.Project{Root: root, Files: files} +} + +func testSyntax(project source.Project, requirements map[string][]*ast.RequireDecl) map[string]*ast.File { + syntax := make(map[string]*ast.File, len(project.Files)) + for _, file := range project.Files { + syntax[file.RelPath] = &ast.File{ + Requirements: requirements[file.RelPath], + } + } + return syntax +} + +func staticRequire(importPath string, alias string) *ast.RequireDecl { + pathSpan := diagnostic.Span{ + StartLine: 2, + StartColumn: 9, + EndLine: 2, + EndColumn: 9 + len(importPath) + 2, + StartOffset: 20, + EndOffset: 20 + len(importPath) + 2, + } + path := &ast.StringExpr{ + NodeBase: ast.NodeBase{SourceSpan: pathSpan}, + Quote: '"', + Parts: []ast.StringPart{ + &ast.StringText{ + NodeBase: ast.NodeBase{SourceSpan: pathSpan}, + Raw: importPath, + Value: importPath, + }, + }, + } + requirement := &ast.RequireDecl{ + NodeBase: ast.NodeBase{SourceSpan: diagnostic.Span{ + StartLine: 2, + StartColumn: 1, + EndLine: 2, + EndColumn: pathSpan.EndColumn, + StartOffset: 12, + EndOffset: pathSpan.EndOffset, + }}, + Path: path, + } + if alias != "" { + requirement.Alias = &ast.Identifier{ + NodeBase: ast.NodeBase{SourceSpan: diagnostic.Span{ + StartLine: 2, + StartColumn: pathSpan.EndColumn + 4, + EndLine: 2, + EndColumn: pathSpan.EndColumn + 4 + len(alias), + StartOffset: pathSpan.EndOffset + 4, + EndOffset: pathSpan.EndOffset + 4 + len(alias), + }}, + Name: alias, + } + } + return requirement +} + +func interpolatedRequire() *ast.RequireDecl { + requirement := staticRequire("abc/1", "") + requirement.Path.Parts = []ast.StringPart{ + &ast.StringText{Raw: "abc/", Value: "abc/"}, + &ast.StringInterpolation{ + Expression: &ast.IntLiteral{Value: 1}, + }, + } + return requirement +} + +func requireTestModule(t *testing.T, project *Project, relPath string) *Module { + t.Helper() + if project == nil { + t.Fatal("expected resolved project") + } + module, ok := project.Module(relPath) + if !ok { + t.Fatalf("expected module %q", relPath) + } + return module +} + +func assertNoDiagnostics(t *testing.T, diagnostics []diagnostic.Diagnostic) { + t.Helper() + if len(diagnostics) != 0 { + t.Fatalf("expected no diagnostics, got %#v", diagnostics) + } +} + +func assertSingleDiagnostic( + t *testing.T, + diagnostics []diagnostic.Diagnostic, + want diagnostic.Diagnostic, +) { + t.Helper() + if len(diagnostics) != 1 { + t.Fatalf("expected one diagnostic, 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/alias/puff.toml b/src/internal/sema/testdata/alias/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/alias/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/alias/src/abc/shop.puff b/src/internal/sema/testdata/alias/src/abc/shop.puff new file mode 100644 index 0000000..2277d1c --- /dev/null +++ b/src/internal/sema/testdata/alias/src/abc/shop.puff @@ -0,0 +1,2 @@ +pub fun Run +end diff --git a/src/internal/sema/testdata/alias/src/main.puff b/src/internal/sema/testdata/alias/src/main.puff new file mode 100644 index 0000000..4aee530 --- /dev/null +++ b/src/internal/sema/testdata/alias/src/main.puff @@ -0,0 +1 @@ +require "abc/shop" as economy diff --git a/src/internal/sema/testdata/ambiguous/puff.toml b/src/internal/sema/testdata/ambiguous/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/ambiguous/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/ambiguous/src/abc/shop.puff b/src/internal/sema/testdata/ambiguous/src/abc/shop.puff new file mode 100644 index 0000000..e7f6d85 --- /dev/null +++ b/src/internal/sema/testdata/ambiguous/src/abc/shop.puff @@ -0,0 +1,2 @@ +pub fun Direct +end diff --git a/src/internal/sema/testdata/ambiguous/src/abc/shop/main.puff b/src/internal/sema/testdata/ambiguous/src/abc/shop/main.puff new file mode 100644 index 0000000..d339900 --- /dev/null +++ b/src/internal/sema/testdata/ambiguous/src/abc/shop/main.puff @@ -0,0 +1,2 @@ +pub fun Directory +end diff --git a/src/internal/sema/testdata/ambiguous/src/main.puff b/src/internal/sema/testdata/ambiguous/src/main.puff new file mode 100644 index 0000000..05f70bb --- /dev/null +++ b/src/internal/sema/testdata/ambiguous/src/main.puff @@ -0,0 +1 @@ +require "abc/shop" diff --git a/src/internal/sema/testdata/custom-source/code/abc/shop.puff b/src/internal/sema/testdata/custom-source/code/abc/shop.puff new file mode 100644 index 0000000..2277d1c --- /dev/null +++ b/src/internal/sema/testdata/custom-source/code/abc/shop.puff @@ -0,0 +1,2 @@ +pub fun Run +end diff --git a/src/internal/sema/testdata/custom-source/code/main.puff b/src/internal/sema/testdata/custom-source/code/main.puff new file mode 100644 index 0000000..05f70bb --- /dev/null +++ b/src/internal/sema/testdata/custom-source/code/main.puff @@ -0,0 +1 @@ +require "abc/shop" diff --git a/src/internal/sema/testdata/custom-source/puff.toml b/src/internal/sema/testdata/custom-source/puff.toml new file mode 100644 index 0000000..6cf2727 --- /dev/null +++ b/src/internal/sema/testdata/custom-source/puff.toml @@ -0,0 +1,8 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" + +[build] +source = "code" diff --git a/src/internal/sema/testdata/direct-file/puff.toml b/src/internal/sema/testdata/direct-file/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/direct-file/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/direct-file/src/abc/shop.puff b/src/internal/sema/testdata/direct-file/src/abc/shop.puff new file mode 100644 index 0000000..2277d1c --- /dev/null +++ b/src/internal/sema/testdata/direct-file/src/abc/shop.puff @@ -0,0 +1,2 @@ +pub fun Run +end diff --git a/src/internal/sema/testdata/direct-file/src/main.puff b/src/internal/sema/testdata/direct-file/src/main.puff new file mode 100644 index 0000000..05f70bb --- /dev/null +++ b/src/internal/sema/testdata/direct-file/src/main.puff @@ -0,0 +1 @@ +require "abc/shop" diff --git a/src/internal/sema/testdata/directory-module/puff.toml b/src/internal/sema/testdata/directory-module/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/directory-module/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/directory-module/src/abc/shop/main.puff b/src/internal/sema/testdata/directory-module/src/abc/shop/main.puff new file mode 100644 index 0000000..2277d1c --- /dev/null +++ b/src/internal/sema/testdata/directory-module/src/abc/shop/main.puff @@ -0,0 +1,2 @@ +pub fun Run +end diff --git a/src/internal/sema/testdata/directory-module/src/main.puff b/src/internal/sema/testdata/directory-module/src/main.puff new file mode 100644 index 0000000..05f70bb --- /dev/null +++ b/src/internal/sema/testdata/directory-module/src/main.puff @@ -0,0 +1 @@ +require "abc/shop" diff --git a/src/internal/sema/testdata/invalid-import/puff.toml b/src/internal/sema/testdata/invalid-import/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/invalid-import/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/invalid-import/src/main.puff b/src/internal/sema/testdata/invalid-import/src/main.puff new file mode 100644 index 0000000..205010c --- /dev/null +++ b/src/internal/sema/testdata/invalid-import/src/main.puff @@ -0,0 +1,2 @@ +require "" +require "abc/{1}" diff --git a/src/internal/sema/testdata/invalid-prefix-aliased/puff.toml b/src/internal/sema/testdata/invalid-prefix-aliased/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/invalid-prefix-aliased/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/invalid-prefix-aliased/src/github.com/123/123.puff b/src/internal/sema/testdata/invalid-prefix-aliased/src/github.com/123/123.puff new file mode 100644 index 0000000..2277d1c --- /dev/null +++ b/src/internal/sema/testdata/invalid-prefix-aliased/src/github.com/123/123.puff @@ -0,0 +1,2 @@ +pub fun Run +end diff --git a/src/internal/sema/testdata/invalid-prefix-aliased/src/main.puff b/src/internal/sema/testdata/invalid-prefix-aliased/src/main.puff new file mode 100644 index 0000000..d12c486 --- /dev/null +++ b/src/internal/sema/testdata/invalid-prefix-aliased/src/main.puff @@ -0,0 +1 @@ +require "github.com/123/123" as lib123 diff --git a/src/internal/sema/testdata/invalid-prefix/puff.toml b/src/internal/sema/testdata/invalid-prefix/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/invalid-prefix/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/invalid-prefix/src/github.com/123/123.puff b/src/internal/sema/testdata/invalid-prefix/src/github.com/123/123.puff new file mode 100644 index 0000000..2277d1c --- /dev/null +++ b/src/internal/sema/testdata/invalid-prefix/src/github.com/123/123.puff @@ -0,0 +1,2 @@ +pub fun Run +end diff --git a/src/internal/sema/testdata/invalid-prefix/src/main.puff b/src/internal/sema/testdata/invalid-prefix/src/main.puff new file mode 100644 index 0000000..a23b30e --- /dev/null +++ b/src/internal/sema/testdata/invalid-prefix/src/main.puff @@ -0,0 +1 @@ +require "github.com/123/123" diff --git a/src/internal/sema/testdata/missing/puff.toml b/src/internal/sema/testdata/missing/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/missing/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/missing/src/main.puff b/src/internal/sema/testdata/missing/src/main.puff new file mode 100644 index 0000000..05f70bb --- /dev/null +++ b/src/internal/sema/testdata/missing/src/main.puff @@ -0,0 +1 @@ +require "abc/shop" diff --git a/src/internal/sema/testdata/prefix-required/puff.toml b/src/internal/sema/testdata/prefix-required/puff.toml new file mode 100644 index 0000000..2eee7ce --- /dev/null +++ b/src/internal/sema/testdata/prefix-required/puff.toml @@ -0,0 +1,5 @@ +[pack] +id = "test" + +[minecraft] +versions = "1.21" diff --git a/src/internal/sema/testdata/prefix-required/src/abc/shop.puff b/src/internal/sema/testdata/prefix-required/src/abc/shop.puff new file mode 100644 index 0000000..d4953cb --- /dev/null +++ b/src/internal/sema/testdata/prefix-required/src/abc/shop.puff @@ -0,0 +1,5 @@ +pub $tax = 0.1 + +pub fun finalPrice(price: float) -> float + return price + 10 +end diff --git a/src/internal/sema/testdata/prefix-required/src/main.puff b/src/internal/sema/testdata/prefix-required/src/main.puff new file mode 100644 index 0000000..f08c1ae --- /dev/null +++ b/src/internal/sema/testdata/prefix-required/src/main.puff @@ -0,0 +1,4 @@ +require "abc/shop" + +$result = shop.finalPrice(100) +$copy = shop.$tax