From 1cf9fd4e16653978e3867f00a56e918e1178493d Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:05:07 +0600 Subject: [PATCH 01/12] Remediate manifest, dependency, and linker integrity Harden manifest discovery, init safety, lockfile migration, dependency content pinning, and module-aware callable names.\n\nCentralize atomic publishing, checksum framing, module identity, and callable mangling to preserve validation and ABI invariants without compatibility wrappers.\n\nValidation: go test ./...; bundled x_test; go vet ./... --- README.md | 2 +- cmd/check_subprocess_test.go | 6 +- cmd/cli/cleanup.go | 2 +- cmd/cli/get.go | 53 +++-- cmd/cli/get_test.go | 212 ++++++++++++++++++ cmd/cli/init.go | 102 +++++++-- cmd/cli/init_test.go | 125 +++++++++++ cmd/cli/list.go | 48 ++-- cmd/cli/list_test.go | 37 +++ cmd/cli/orphans.go | 2 +- cmd/cli/remove.go | 3 - cmd/command.go | 5 +- cmd/command_test.go | 56 +++++ cmd/dispatch.go | 38 ++-- cmd/dispatch_test.go | 61 +++++ cmd/init_subprocess_test.go | 82 +++++++ internal/driver/compiler.go | 2 - internal/ir/hir/lower/lower_interface.go | 2 +- internal/ir/hir/lower/module_lower.go | 110 ++++----- internal/ir/hir/lower/module_lower_test.go | 85 +++++-- internal/lsp/server_test.go | 34 +++ internal/lsp/state.go | 6 +- internal/pipeline/pipeline.go | 1 + internal/pipeline/pipeline_test.go | 5 +- internal/project/modules.go | 12 + internal/semantics/collector/collector.go | 2 + .../semantics/collector/collector_test.go | 37 +++ internal/semantics/symbols/symbol.go | 34 +-- pkg/manifest/lockfile.go | 203 +++++++++-------- pkg/manifest/lockfile_test.go | 180 +++++++++++++-- pkg/manifest/manifest.go | 48 +++- pkg/manifest/manifest_test.go | 91 +++++++- pkg/manifest/write.go | 3 +- pkg/peeper/constants.go | 3 + pkg/registry/cache.go | 23 -- pkg/registry/cache_test.go | 61 +---- pkg/registry/checksum.go | 86 +++++++ pkg/registry/checksum_test.go | 94 ++++++++ pkg/registry/download.go | 113 +++++++--- pkg/registry/download_test.go | 124 +++++++++- .../src/main.peep | 3 +- x_test/owned_pointer_carrier/peeper.toml | 2 +- x_test/review_consteval_gaps/peeper.toml | 2 +- .../runtime_module_callable_names/peeper.toml | 6 + .../src/alpha.peep | 16 ++ .../src/beta.peep | 16 ++ .../src/main.peep | 6 + 47 files changed, 1816 insertions(+), 428 deletions(-) create mode 100644 cmd/cli/init_test.go create mode 100644 cmd/cli/list_test.go create mode 100644 cmd/init_subprocess_test.go create mode 100644 pkg/registry/checksum.go create mode 100644 pkg/registry/checksum_test.go create mode 100644 x_test/runtime_module_callable_names/peeper.toml create mode 100644 x_test/runtime_module_callable_names/src/alpha.peep create mode 100644 x_test/runtime_module_callable_names/src/beta.peep create mode 100644 x_test/runtime_module_callable_names/src/main.peep diff --git a/README.md b/README.md index b122814..8efe5bc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Generated source starts with: ```peeper fn main() { - println("Hello from Peeper!") + println("Hello from Peeper!"); } ``` diff --git a/cmd/check_subprocess_test.go b/cmd/check_subprocess_test.go index a31a252..2b21297 100644 --- a/cmd/check_subprocess_test.go +++ b/cmd/check_subprocess_test.go @@ -12,11 +12,7 @@ import ( func TestCheckCommandSupportsRecursiveAndMultipleTargetsWithFailureStatus(t *testing.T) { root := t.TempDir() - binary := filepath.Join(root, "peeper") - build := exec.Command("go", "build", "-o", binary, ".") - if output, err := build.CombinedOutput(); err != nil { - t.Fatalf("build CLI: %v\n%s", err, output) - } + binary := buildTestCLI(t) validDir := filepath.Join(root, "valid") invalidOneDir := filepath.Join(root, "invalid-one") invalidTwoDir := filepath.Join(root, "invalid-two") diff --git a/cmd/cli/cleanup.go b/cmd/cli/cleanup.go index 1d84a6e..0abebac 100644 --- a/cmd/cli/cleanup.go +++ b/cmd/cli/cleanup.go @@ -8,7 +8,7 @@ import ( "compiler/pkg/manifest" ) -func CleanupCommand(args []string) error { +func CleanupCommand(_ []string) error { manifestPath, err := manifest.FindManifestPath(".") if err != nil { return err diff --git a/cmd/cli/get.go b/cmd/cli/get.go index 8142c9f..88b4507 100644 --- a/cmd/cli/get.go +++ b/cmd/cli/get.go @@ -162,31 +162,18 @@ func installPackageRecursive(httpClient *http.Client, cachePath, repoPath, versi packageID = manifest.PackageID(repoPath, version) } printPackage(repoPath, version) - if !registry.IsModuleCached(cachePath, repoPath, version) { - printDownload(fmt.Sprintf("Downloading %s@%s...", repoPath, version)) - if err := registry.DownloadRemotePackage(httpClient, cachePath, repoPath, version, devConfig); err != nil { - return fmt.Errorf("download %s@%s: %w", repoPath, version, err) - } - } - printCached() - - modulePath, err := registry.GetModulePath(cachePath, repoPath, version) + entry, exists := lockfile.GetDependency(packageID) + modulePath, checksum, err := ensurePackageContent(httpClient, cachePath, repoPath, version, devConfig, entry, exists) if err != nil { return err } + printCached() + packageManifest, err := manifest.Load(filepath.Join(modulePath, manifest.FileName)) if err != nil { return fmt.Errorf("load package manifest for %s: %w", repoPath, err) } - transitiveDeps := make([]string, 0) - for _, dep := range packageManifest.Dependencies { - if dep.Type == manifest.DependencyRemote { - transitiveDeps = append(transitiveDeps, dep.Path) - } - } - - entry, exists := lockfile.GetDependency(packageID) usedBy := []string{} existingDependencies := []string{} if exists { @@ -196,6 +183,7 @@ func installPackageRecursive(httpClient *http.Client, cachePath, repoPath, versi newEntry := manifest.LockfileEntry{ Version: version, ResolvedURL: repoPath, + Checksum: checksum, Direct: directAlias != "", Description: packageManifest.Package.Name, Dependencies: existingDependencies, @@ -245,6 +233,37 @@ func installPackageRecursive(httpClient *http.Client, cachePath, repoPath, versi return nil } +func ensurePackageContent(httpClient *http.Client, cachePath, repoPath, version string, devConfig *manifest.DevConfig, entry manifest.LockfileEntry, locked bool) (string, string, error) { + modulePath, err := registry.GetModulePath(cachePath, repoPath, version) + if err != nil { + return "", "", err + } + expectedChecksum := "" + if locked && entry.Checksum != "" { + checksum, hashErr := registry.ModuleChecksum(modulePath) + if hashErr == nil && checksum == entry.Checksum { + return modulePath, checksum, nil + } + expectedChecksum = entry.Checksum + } else if locked { + if _, statErr := os.Lstat(modulePath); statErr == nil { + expectedChecksum, err = registry.ModuleChecksum(modulePath) + if err != nil { + return "", "", fmt.Errorf("hash legacy cache for %s@%s: %w", repoPath, version, err) + } + } else if !os.IsNotExist(statErr) { + return "", "", fmt.Errorf("inspect legacy cache for %s@%s: %w", repoPath, version, statErr) + } + } + + printDownload(fmt.Sprintf("Downloading %s@%s...", repoPath, version)) + checksum, err := registry.DownloadRemotePackage(httpClient, cachePath, repoPath, version, expectedChecksum, devConfig) + if err != nil { + return "", "", fmt.Errorf("download %s@%s: %w", repoPath, version, err) + } + return modulePath, checksum, nil +} + func installPackage(ctx *installContext, packageSpec string) (string, error) { dep, err := manifest.ParseDependency(packageSpec) if err != nil { diff --git a/cmd/cli/get_test.go b/cmd/cli/get_test.go index 52ac22e..5658478 100644 --- a/cmd/cli/get_test.go +++ b/cmd/cli/get_test.go @@ -4,9 +4,11 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" "compiler/pkg/manifest" + "compiler/pkg/registry" ) func TestInstallAllDependenciesRestoresMissingLockedCache(t *testing.T) { @@ -72,6 +74,216 @@ build = "lib" if got := loadedManifest.Dependencies["peeper_test_lib"].Version; got != "v0.0.1" { t.Fatalf("expected dependency to be pinned to resolved version, got %q", got) } + lock, err := manifest.LoadLockfile(root) + if err != nil { + t.Fatal(err) + } + entry, ok := lock.GetDependency("github.com/itsfuad/peeper_test_lib@v0.0.1") + if !ok || entry.Checksum == "" { + t.Fatalf("legacy dependency was not checksum-pinned: %#v", entry) + } +} + +func TestInstallDependencyPinsReusesAndRepairsCache(t *testing.T) { + root := t.TempDir() + mockPackage := filepath.Join(root, "mock", "acme", "pkg-v1.0.0") + cachePackage := filepath.Join(manifest.CacheModulesDir(root), "github.com", "acme", "pkg@v1.0.0") + mustWriteGetTest(t, filepath.Join(root, manifest.FileName), `name = "app" +build = "program" + +[dependencies] +pkg = "github.com/acme/pkg" + +[dev] +mock_remote = true +mock_path = "./mock" +`) + mustWriteGetTest(t, filepath.Join(mockPackage, manifest.FileName), "name = \"pkg\"\nbuild = \"lib\"\n") + mustWriteGetTest(t, filepath.Join(mockPackage, "src", "pkg.peep"), "original") + mustWriteGetTest(t, filepath.Join(cachePackage, manifest.FileName), "name = \"stale\"\nbuild = \"lib\"\n") + mustWriteGetTest(t, filepath.Join(cachePackage, "src", "pkg.peep"), "unlocked-cache") + t.Chdir(root) + + if err := installAllDependencies(); err != nil { + t.Fatal(err) + } + lock, err := manifest.LoadLockfile(root) + if err != nil { + t.Fatal(err) + } + entry, ok := lock.GetDependency("github.com/acme/pkg@v1.0.0") + if !ok || entry.Checksum == "" { + t.Fatalf("initial install entry = %#v", entry) + } + if checksum, err := registry.ModuleChecksum(cachePackage); err != nil || checksum != entry.Checksum { + t.Fatalf("cache checksum = %q, err=%v, want %q", checksum, err, entry.Checksum) + } + if data, err := os.ReadFile(filepath.Join(cachePackage, "src", "pkg.peep")); err != nil || string(data) != "original" { + t.Fatalf("new resolution reused unpinned cache: %q, err=%v", data, err) + } + + offlineMock := mockPackage + ".offline" + if err := os.Rename(mockPackage, offlineMock); err != nil { + t.Fatal(err) + } + if err := installAllDependencies(); err != nil { + t.Fatalf("valid cache triggered refetch: %v", err) + } + if err := os.Rename(offlineMock, mockPackage); err != nil { + t.Fatal(err) + } + + cacheSource := filepath.Join(cachePackage, "src", "pkg.peep") + if err := os.WriteFile(cacheSource, []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + if err := installAllDependencies(); err != nil { + t.Fatalf("tampered cache was not repaired: %v", err) + } + if data, err := os.ReadFile(cacheSource); err != nil || string(data) != "original" { + t.Fatalf("repaired source = %q, err=%v", data, err) + } + + if err := os.WriteFile(cacheSource, []byte("tampered-again"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mockPackage, "src", "pkg.peep"), []byte("moved-tag"), 0o644); err != nil { + t.Fatal(err) + } + lockBefore, err := os.ReadFile(filepath.Join(root, manifest.LockfileName)) + if err != nil { + t.Fatal(err) + } + if err := installAllDependencies(); err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("moved tag error = %v", err) + } + lockAfter, err := os.ReadFile(filepath.Join(root, manifest.LockfileName)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(lockAfter, lockBefore) { + t.Fatal("moved tag changed lockfile") + } + if data, err := os.ReadFile(cacheSource); err != nil || string(data) != "tampered-again" { + t.Fatalf("moved tag replaced prior cache: %q, err=%v", data, err) + } +} + +func TestInstallDependencyPinsTransitivePackages(t *testing.T) { + root := t.TempDir() + mustWriteGetTest(t, filepath.Join(root, manifest.FileName), `name = "app" +build = "program" + +[dependencies] +parent = "github.com/acme/parent" + +[dev] +mock_remote = true +mock_path = "./mock" +`) + mustWriteGetTest(t, filepath.Join(root, "mock", "acme", "parent-v1.0.0", manifest.FileName), `name = "parent" +build = "lib" + +[dependencies] +child = "github.com/acme/child" +`) + mustWriteGetTest(t, filepath.Join(root, "mock", "acme", "child-v1.0.0", manifest.FileName), "name = \"child\"\nbuild = \"lib\"\n") + t.Chdir(root) + + if err := installAllDependencies(); err != nil { + t.Fatal(err) + } + lock, err := manifest.LoadLockfile(root) + if err != nil { + t.Fatal(err) + } + for _, packageID := range []string{"github.com/acme/parent@v1.0.0", "github.com/acme/child@v1.0.0"} { + entry, ok := lock.GetDependency(packageID) + if !ok || entry.Checksum == "" { + t.Fatalf("package %s entry = %#v", packageID, entry) + } + } +} + +func TestLegacyChecksumMigrationRequiresMatchingRemote(t *testing.T) { + tests := []struct { + name string + remoteContent string + wantError bool + }{ + {name: "matching", remoteContent: "cached"}, + {name: "disagreement", remoteContent: "moved", wantError: true}, + {name: "offline", wantError: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + cachePackage := filepath.Join(manifest.CacheModulesDir(root), "github.com", "acme", "pkg@v1.0.0") + mustWriteGetTest(t, filepath.Join(root, manifest.FileName), `name = "app" +build = "program" + +[dependencies] +pkg = "github.com/acme/pkg@v1.0.0" + +[dev] +mock_remote = true +mock_path = "./mock" +`) + mustWriteGetTest(t, filepath.Join(cachePackage, manifest.FileName), "name = \"pkg\"\nbuild = \"lib\"\n") + mustWriteGetTest(t, filepath.Join(cachePackage, "src", "pkg.peep"), "cached") + if test.remoteContent != "" { + mockPackage := filepath.Join(root, "mock", "acme", "pkg-v1.0.0") + mustWriteGetTest(t, filepath.Join(mockPackage, manifest.FileName), "name = \"pkg\"\nbuild = \"lib\"\n") + mustWriteGetTest(t, filepath.Join(mockPackage, "src", "pkg.peep"), test.remoteContent) + } + lock := manifest.NewLockfile() + packageID := "github.com/acme/pkg@v1.0.0" + lock.SetDependency(packageID, manifest.LockfileEntry{Version: "v1.0.0", ResolvedURL: "github.com/acme/pkg", Direct: true}) + lock.SetDirectDependency("pkg", packageID) + if err := manifest.SaveLockfile(root, lock); err != nil { + t.Fatal(err) + } + lockBefore, err := os.ReadFile(filepath.Join(root, manifest.LockfileName)) + if err != nil { + t.Fatal(err) + } + cacheBefore, err := registry.ModuleChecksum(cachePackage) + if err != nil { + t.Fatal(err) + } + t.Chdir(root) + + err = installAllDependencies() + if test.wantError { + if err == nil { + t.Fatal("legacy migration succeeded") + } + lockAfter, readErr := os.ReadFile(filepath.Join(root, manifest.LockfileName)) + if readErr != nil { + t.Fatal(readErr) + } + if !bytes.Equal(lockAfter, lockBefore) { + t.Fatal("failed legacy migration changed lockfile") + } + cacheAfter, hashErr := registry.ModuleChecksum(cachePackage) + if hashErr != nil || cacheAfter != cacheBefore { + t.Fatalf("failed legacy migration changed cache: %q, err=%v", cacheAfter, hashErr) + } + return + } + if err != nil { + t.Fatal(err) + } + migrated, err := manifest.LoadLockfile(root) + if err != nil { + t.Fatal(err) + } + entry, ok := migrated.GetDependency(packageID) + if !ok || entry.Checksum != cacheBefore { + t.Fatalf("migrated entry = %#v, want checksum %q", entry, cacheBefore) + } + }) + } } func TestPrepareInstallContextPropagatesMalformedLockfile(t *testing.T) { diff --git a/cmd/cli/init.go b/cmd/cli/init.go index d7d8cfd..f92c053 100644 --- a/cmd/cli/init.go +++ b/cmd/cli/init.go @@ -2,19 +2,23 @@ package cli import ( "bufio" + "errors" "fmt" + "io" "os" "path/filepath" "strings" + "unicode" - "compiler/internal/driver" "compiler/pkg/manifest" "compiler/pkg/peeper" ) -func InitCommand(args []string) error { - if _, err := os.Stat(manifest.FileName); err == nil { +func InitCommand(args []string) (returnErr error) { + if _, err := os.Lstat(manifest.FileName); err == nil { return fmt.Errorf("%s already exists in current directory", manifest.FileName) + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect %s: %w", manifest.FileName, err) } reader := bufio.NewReader(os.Stdin) @@ -28,7 +32,10 @@ func InitCommand(args []string) error { } defaultName := filepath.Base(cwd) fmt.Printf("Project name (%s): ", defaultName) - input, _ := reader.ReadString('\n') + input, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("read project name: %w", err) + } input = strings.TrimSpace(input) if input == "" { projectName = defaultName @@ -37,36 +44,85 @@ func InitCommand(args []string) error { } } - projectName = strings.ToLower(strings.ReplaceAll(projectName, " ", "-")) - - content := fmt.Sprintf(`name = %q -version = "0.0.1" -compiler = "<=%s" -build = "program" - -[dependencies] -`, projectName, compiler.COMPILER_VERSION) - - if err := os.WriteFile(manifest.FileName, []byte(content), 0o644); err != nil { + projectName = strings.Map(func(char rune) rune { + if char == '-' || unicode.IsSpace(char) { + return '_' + } + return unicode.ToLower(char) + }, projectName) + if err := manifest.ValidatePackageName(projectName); err != nil { return err } - if err := os.MkdirAll(peeper.SourceDirName, 0o755); err != nil { - return err + sourceExists := false + if info, err := os.Lstat(peeper.SourceDirName); err == nil { + if !info.IsDir() { + return fmt.Errorf("%s exists and is not a directory", peeper.SourceDirName) + } + sourceExists = true + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect %s: %w", peeper.SourceDirName, err) } + mainPath := filepath.Join(peeper.SourceDirName, peeper.MainFileName) - if _, err := os.Stat(mainPath); os.IsNotExist(err) { - mainContent := ` -fn main() { - println("Hello from Peeper!") + mainExists := false + if sourceExists { + if info, err := os.Lstat(mainPath); err == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf("%s exists and is not a regular file", mainPath) + } + mainExists = true + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect %s: %w", mainPath, err) + } + } + + createdPaths := make([]string, 0, 3) + complete := false + defer func() { + if complete { + return + } + errs := []error{returnErr} + for index := len(createdPaths) - 1; index >= 0; index-- { + if err := os.Remove(createdPaths[index]); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Errorf("remove created %s: %w", createdPaths[index], err)) + } + } + returnErr = errors.Join(errs...) + }() + + if !sourceExists { + if err := os.Mkdir(peeper.SourceDirName, 0o755); err != nil { + return fmt.Errorf("create %s: %w", peeper.SourceDirName, err) + } + createdPaths = append(createdPaths, peeper.SourceDirName) + } + if !mainExists { + createdPaths = append(createdPaths, mainPath) + mainContent := `fn main() { + println("Hello from Peeper!"); } ` - if err := os.WriteFile(mainPath, []byte(mainContent), 0o644); err != nil { - return err + if err := manifest.WriteFileAtomic(mainPath, []byte(mainContent), 0o644); err != nil { + return fmt.Errorf("write %s: %w", mainPath, err) } printSuccess("Created " + mainPath) } + createdPaths = append(createdPaths, manifest.FileName) + manifestContent := fmt.Sprintf(`name = %q +version = "0.0.1" +compiler = "<=%s" +build = "program" + +[dependencies] +`, projectName, peeper.CompilerVersion) + if err := manifest.WriteFileAtomic(manifest.FileName, []byte(manifestContent), 0o644); err != nil { + return fmt.Errorf("write %s: %w", manifest.FileName, err) + } + complete = true + printSuccess(fmt.Sprintf("Initialized project: %s", projectName)) fmt.Println("\nNext steps:") fmt.Printf(" 1. Edit %s to add dependencies\n", manifest.FileName) diff --git a/cmd/cli/init_test.go b/cmd/cli/init_test.go new file mode 100644 index 0000000..bde1def --- /dev/null +++ b/cmd/cli/init_test.go @@ -0,0 +1,125 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "compiler/pkg/manifest" + "compiler/pkg/peeper" +) + +func TestInitCommandRejectsInvalidNamesWithoutArtifacts(t *testing.T) { + for _, name := range []string{"1app", "bad/name", "_app"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + if err := InitCommand([]string{name}); err == nil { + t.Fatalf("InitCommand(%q) succeeded", name) + } + for _, path := range []string{manifest.FileName, peeper.SourceDirName} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("invalid name created %s: %v", path, err) + } + } + }) + } +} + +func TestInitCommandPreflightsPathConflictsBeforeWriting(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T) + }{ + { + name: "src is regular file", + setup: func(t *testing.T) { + if err := os.WriteFile(peeper.SourceDirName, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "main is directory", + setup: func(t *testing.T) { + if err := os.MkdirAll(filepath.Join(peeper.SourceDirName, peeper.MainFileName), 0o755); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "main is symlink", + setup: func(t *testing.T) { + if err := os.Mkdir(peeper.SourceDirName, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(peeper.SourceDirName, "existing"+peeper.SourceExt) + if err := os.WriteFile(target, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Base(target), filepath.Join(peeper.SourceDirName, peeper.MainFileName)); err != nil { + t.Skipf("create symlink: %v", err) + } + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + test.setup(t) + if err := InitCommand([]string{"app"}); err == nil { + t.Fatal("InitCommand succeeded") + } + if _, err := os.Lstat(manifest.FileName); !os.IsNotExist(err) { + t.Fatalf("path conflict left manifest: %v", err) + } + }) + } +} + +func TestInitCommandPreservesExistingRegularMain(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + if err := os.Mkdir(peeper.SourceDirName, 0o755); err != nil { + t.Fatal(err) + } + mainPath := filepath.Join(peeper.SourceDirName, peeper.MainFileName) + original := []byte("fn main() {\n\tprintln(\"custom\");\n}\n") + if err := os.WriteFile(mainPath, original, 0o640); err != nil { + t.Fatal(err) + } + + if err := InitCommand([]string{"app"}); err != nil { + t.Fatalf("InitCommand: %v", err) + } + after, err := os.ReadFile(mainPath) + if err != nil { + t.Fatal(err) + } + if string(after) != string(original) { + t.Fatalf("existing main changed:\n%s", after) + } + info, err := os.Stat(mainPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o640 { + t.Fatalf("existing main mode = %o, want 640", info.Mode().Perm()) + } +} + +func TestInitCommandNormalizesWhitespace(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + if err := InitCommand([]string{"Hello Peeper"}); err != nil { + t.Fatalf("InitCommand: %v", err) + } + file, err := manifest.Load(manifest.FileName) + if err != nil { + t.Fatalf("load generated manifest: %v", err) + } + if file.Package.Name != "hello_peeper" { + t.Fatalf("package name = %q, want hello_peeper", file.Package.Name) + } +} diff --git a/cmd/cli/list.go b/cmd/cli/list.go index 443aee6..82b7721 100644 --- a/cmd/cli/list.go +++ b/cmd/cli/list.go @@ -7,7 +7,7 @@ import ( "compiler/pkg/manifest" ) -func ListCommand(args []string) error { +func ListCommand(_ []string) error { manifestPath, err := manifest.FindManifestPath(".") if err != nil { return err @@ -18,8 +18,10 @@ func ListCommand(args []string) error { } projectRoot := filepath.Dir(manifestPath) - lockfile, lockErr := manifest.LoadLockfile(projectRoot) - hasLockfile := lockErr == nil + lockfile, err := manifest.LoadLockfile(projectRoot) + if err != nil { + return err + } fmt.Printf("%s v%s\n", file.Package.Name, file.Package.Version) if len(file.Dependencies) == 0 { @@ -37,35 +39,31 @@ func ListCommand(args []string) error { fmt.Printf(" %s (remote)\n", name) fmt.Printf(" URL: %s\n", dep.Path) fmt.Printf(" Constraint: %s\n", dep.Version) - if hasLockfile { - if packageID, ok := lockfile.GetDirectDependency(name); ok { - if entry, found := lockfile.GetDependency(packageID); found { - fmt.Printf(" Locked: %s (%s)\n", entry.Version, packageID) - } + if packageID, ok := lockfile.GetDirectDependency(name); ok { + if entry, found := lockfile.GetDependency(packageID); found { + fmt.Printf(" Locked: %s (%s)\n", entry.Version, packageID) } } } } - if hasLockfile { - transitiveCount := 0 - entries := lockfile.Packages - if len(entries) == 0 { - entries = lockfile.Dependencies + transitiveCount := 0 + entries := lockfile.Packages + if len(entries) == 0 { + entries = lockfile.Dependencies + } + for _, entry := range entries { + if !entry.Direct { + transitiveCount++ } - for _, entry := range entries { + } + if transitiveCount > 0 { + fmt.Printf("\nTransitive dependencies (%d):\n", transitiveCount) + for depName, entry := range entries { if !entry.Direct { - transitiveCount++ - } - } - if transitiveCount > 0 { - fmt.Printf("\nTransitive dependencies (%d):\n", transitiveCount) - for depName, entry := range entries { - if !entry.Direct { - fmt.Printf(" %s @ %s\n", depName, entry.Version) - if len(entry.UsedBy) > 0 { - fmt.Printf(" Used by: %v\n", entry.UsedBy) - } + fmt.Printf(" %s @ %s\n", depName, entry.Version) + if len(entry.UsedBy) > 0 { + fmt.Printf(" Used by: %v\n", entry.UsedBy) } } } diff --git a/cmd/cli/list_test.go b/cmd/cli/list_test.go new file mode 100644 index 0000000..ba93770 --- /dev/null +++ b/cmd/cli/list_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "compiler/pkg/manifest" +) + +func TestListCommandPropagatesMalformedLockfile(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, manifest.FileName), []byte("name = \"app\"\nbuild = \"program\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, manifest.LockfileName), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(root) + + if err := ListCommand(nil); err == nil { + t.Fatal("ListCommand ignored malformed lockfile") + } +} + +func TestListCommandAllowsMissingLockfile(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, manifest.FileName), []byte("name = \"app\"\nbuild = \"program\"\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(root) + + if err := ListCommand(nil); err != nil { + t.Fatalf("ListCommand with no lockfile: %v", err) + } +} diff --git a/cmd/cli/orphans.go b/cmd/cli/orphans.go index 80248ce..ab5ed61 100644 --- a/cmd/cli/orphans.go +++ b/cmd/cli/orphans.go @@ -8,7 +8,7 @@ import ( "compiler/pkg/manifest" ) -func OrphansCommand(args []string) error { +func OrphansCommand(_ []string) error { manifestPath, err := manifest.FindManifestPath(".") if err != nil { return err diff --git a/cmd/cli/remove.go b/cmd/cli/remove.go index c4fcd3c..3bc7cbb 100644 --- a/cmd/cli/remove.go +++ b/cmd/cli/remove.go @@ -8,9 +8,6 @@ import ( ) func RemoveCommand(args []string) error { - if len(args) == 0 { - return fmt.Errorf("usage: peeper remove ") - } packageName := args[0] manifestPath, err := manifest.FindManifestPath(".") diff --git a/cmd/command.go b/cmd/command.go index 9f228cd..bc4c83d 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -298,7 +298,10 @@ func resolveBuildTarget(commandName, path string, targetOS string) (resolvedPath func resolveManifestBuildTarget(commandName, startPath string, targetOS string) (buildTarget, error) { loadedProject, err := manifest.LoadProject(startPath) if err != nil { - return buildTarget{}, fmt.Errorf("%s requires an input file or %s", commandName, manifest.FileName) + if errors.Is(err, manifest.ErrManifestNotFound) { + return buildTarget{}, fmt.Errorf("%s requires an input file or %s", commandName, manifest.FileName) + } + return buildTarget{}, err } if loadedProject.File.Package.Build != manifest.BuildProgram { return buildTarget{}, fmt.Errorf("%s: `peeper %s` requires build = %q", loadedProject.ManifestPath, commandName, manifest.BuildProgram) diff --git a/cmd/command_test.go b/cmd/command_test.go index aa2dbc1..c29ba26 100644 --- a/cmd/command_test.go +++ b/cmd/command_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "compiler/pkg/manifest" @@ -125,6 +126,61 @@ build = "program" } } +func TestResolveBuildTargetPropagatesMalformedManifest(t *testing.T) { + root := t.TempDir() + entryPath := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + if err := os.MkdirAll(filepath.Dir(entryPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(entryPath, []byte("fn main() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, manifest.FileName), []byte("not valid toml"), 0o644); err != nil { + t.Fatal(err) + } + + for _, path := range []string{root, entryPath} { + if _, _, err := resolveBuildTarget("build", path, "linux"); err == nil || !strings.Contains(err.Error(), "parse manifest") { + t.Fatalf("resolveBuildTarget(%q) error = %v, want parse manifest error", path, err) + } + } +} + +func TestResolveBuildTargetValidatesCompilerConstraint(t *testing.T) { + tests := []struct { + name string + constraint string + wantError string + }{ + {name: "compatible", constraint: "<=0.1.0"}, + {name: "incompatible", constraint: ">=0.2.0", wantError: "requires compiler"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + entryPath := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + if err := os.MkdirAll(filepath.Dir(entryPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(entryPath, []byte("fn main() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + content := "name = \"app\"\ncompiler = \"" + test.constraint + "\"\nbuild = \"program\"\n" + if err := os.WriteFile(filepath.Join(root, manifest.FileName), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + _, _, err := resolveBuildTarget("build", root, "linux") + if test.wantError == "" && err != nil { + t.Fatalf("resolveBuildTarget compatible manifest: %v", err) + } + if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) { + t.Fatalf("resolveBuildTarget error = %v, want text %q", err, test.wantError) + } + }) + } +} + func TestResolveBuildTargetAppendsWindowsSuffix(t *testing.T) { root := t.TempDir() entryPath := filepath.Join(root, "demo"+peeper.SourceExt) diff --git a/cmd/dispatch.go b/cmd/dispatch.go index 0869ec1..fd44671 100644 --- a/cmd/dispatch.go +++ b/cmd/dispatch.go @@ -8,7 +8,6 @@ import ( "slices" "compiler/cmd/cli" - "compiler/internal/driver" "compiler/internal/lsp" "compiler/pkg/colors" "compiler/pkg/manifest" @@ -19,6 +18,7 @@ const ( exitCodeOK = 0 exitCodeError = 1 exitCodeUsage = 2 + unboundedArgs = -1 ) type programExitStatus int @@ -51,7 +51,7 @@ func parseAndRunCommand(args []string) bool { if !ok { return false } - exitOnCommandError(command.Handler(args[1:])) + exitOnCommandError(command.run(args[1:])) return true } @@ -60,18 +60,27 @@ type commandDefinition struct { Aliases []string Usage string Description string + MinArgs int + MaxArgs int Handler func([]string) error } +func (command commandDefinition) run(args []string) error { + if len(args) < command.MinArgs || command.MaxArgs != unboundedArgs && len(args) > command.MaxArgs { + return fmt.Errorf("usage: peeper %s", command.Usage) + } + return command.Handler(args) +} + var commandRegistry = []commandDefinition{ - {Name: "build", Aliases: []string{"build:llvm"}, Usage: "build[:llvm] [path]", Description: fmt.Sprintf("build program or use %s/%s from %s", peeper.SourceDirName, peeper.MainFileName, manifest.FileName), Handler: buildCommand}, - {Name: "run", Aliases: []string{"run:llvm"}, Usage: "run[:llvm] [path] [args]", Description: "build and run program", Handler: runCommand}, - {Name: "check", Aliases: []string{"lint"}, Usage: "check|lint [path ...]", Description: fmt.Sprintf("typecheck files or folders recursively (%s only)", peeper.SourceExt), Handler: checkCommand}, - {Name: "init", Usage: "init [name]", Description: fmt.Sprintf("create project with %s", manifest.FileName), Handler: cli.InitCommand}, - {Name: "get", Usage: "get [pkg ...]", Description: fmt.Sprintf("install dependencies from %s or named packages", manifest.FileName), Handler: cli.GetCommand}, - {Name: "update", Usage: "update [pkg ...]", Description: "update locked dependencies", Handler: cli.UpdateCommand}, - {Name: "sniff", Usage: "sniff [pkg ...]", Description: "preview dependency updates", Handler: cli.SniffCommand}, - {Name: "remove", Aliases: []string{"rm"}, Usage: "remove|rm ", Description: fmt.Sprintf("remove dependency from %s and %s", manifest.FileName, manifest.LockfileName), Handler: cli.RemoveCommand}, + {Name: "build", Aliases: []string{"build:llvm"}, Usage: "build[:llvm] [path]", Description: fmt.Sprintf("build program or use %s/%s from %s", peeper.SourceDirName, peeper.MainFileName, manifest.FileName), MaxArgs: unboundedArgs, Handler: buildCommand}, + {Name: "run", Aliases: []string{"run:llvm"}, Usage: "run[:llvm] [path] [args]", Description: "build and run program", MaxArgs: unboundedArgs, Handler: runCommand}, + {Name: "check", Aliases: []string{"lint"}, Usage: "check|lint [path ...]", Description: fmt.Sprintf("typecheck files or folders recursively (%s only)", peeper.SourceExt), MaxArgs: unboundedArgs, Handler: checkCommand}, + {Name: "init", Usage: "init [name]", Description: fmt.Sprintf("create project with %s", manifest.FileName), MaxArgs: 1, Handler: cli.InitCommand}, + {Name: "get", Usage: "get [pkg ...]", Description: fmt.Sprintf("install dependencies from %s or named packages", manifest.FileName), MaxArgs: unboundedArgs, Handler: cli.GetCommand}, + {Name: "update", Usage: "update [pkg ...]", Description: "update locked dependencies", MaxArgs: unboundedArgs, Handler: cli.UpdateCommand}, + {Name: "sniff", Usage: "sniff [pkg ...]", Description: "preview dependency updates", MaxArgs: unboundedArgs, Handler: cli.SniffCommand}, + {Name: "remove", Aliases: []string{"rm"}, Usage: "remove|rm ", Description: fmt.Sprintf("remove dependency from %s and %s", manifest.FileName, manifest.LockfileName), MinArgs: 1, MaxArgs: 1, Handler: cli.RemoveCommand}, {Name: "list", Aliases: []string{"ls"}, Usage: "list|ls", Description: "list direct and transitive dependencies", Handler: cli.ListCommand}, {Name: "cleanup", Aliases: []string{"clean"}, Usage: "cleanup|clean", Description: "remove orphaned cached dependencies", Handler: cli.CleanupCommand}, {Name: "orphans", Usage: "orphans", Description: "list orphaned cache and lock entries", Handler: cli.OrphansCommand}, @@ -90,10 +99,7 @@ func lookupCommand(name string) (commandDefinition, bool) { return commandDefinition{}, false } -func lspCommand(args []string) error { - if len(args) != 0 { - return fmt.Errorf("lsp accepts no arguments") - } +func lspCommand(_ []string) error { colors.CYAN.Fprintln(os.Stderr, "starting Peeper LSP server...") return lsp.Run(os.Stdin, os.Stdout) } @@ -108,7 +114,7 @@ func printUsageAndExit(code int) { } } if *showVersion { - fmt.Printf("v%s\n", compiler.COMPILER_VERSION) + fmt.Printf("v%s\n", peeper.CompilerVersion) os.Exit(exitCodeOK) } printTopLevelUsage() @@ -131,7 +137,7 @@ func defineTopLevelFlags() *bool { // printTopLevelUsage writes the program's usage banner to stderr. func printTopLevelUsage() { - colors.BLUE.Fprintln(os.Stderr, "Peeper compiler v"+compiler.COMPILER_VERSION) + colors.BLUE.Fprintln(os.Stderr, "Peeper compiler v"+peeper.CompilerVersion) colors.CYAN.Fprintln(os.Stderr, "\nUsage:") colors.GREEN.Fprintf(os.Stderr, " peeper [command] [args]\n") colors.CYAN.Fprintln(os.Stderr, "\nCommands:") diff --git a/cmd/dispatch_test.go b/cmd/dispatch_test.go index c89c42b..5eebc3a 100644 --- a/cmd/dispatch_test.go +++ b/cmd/dispatch_test.go @@ -27,6 +27,9 @@ func TestCommandRegistryHasUniqueNamesAndRequiredAliases(t *testing.T) { if command.Name == "" || command.Usage == "" || command.Description == "" || command.Handler == nil { t.Fatalf("incomplete command definition: %#v", command) } + if command.MinArgs < 0 || command.MaxArgs < unboundedArgs || command.MaxArgs != unboundedArgs && command.MaxArgs < command.MinArgs { + t.Fatalf("invalid command arity: %#v", command) + } for _, name := range append([]string{command.Name}, command.Aliases...) { if owner, duplicate := seen[name]; duplicate { t.Fatalf("command name %q shared by %q and %q", name, owner, command.Name) @@ -41,6 +44,64 @@ func TestCommandRegistryHasUniqueNamesAndRequiredAliases(t *testing.T) { } } +func TestCommandRegistryArityContracts(t *testing.T) { + tests := []struct { + name string + min int + max int + }{ + {name: "init", min: 0, max: 1}, + {name: "remove", min: 1, max: 1}, + {name: "list", min: 0, max: 0}, + {name: "cleanup", min: 0, max: 0}, + {name: "orphans", min: 0, max: 0}, + {name: "lsp", min: 0, max: 0}, + {name: "build", min: 0, max: unboundedArgs}, + {name: "run", min: 0, max: unboundedArgs}, + {name: "check", min: 0, max: unboundedArgs}, + {name: "get", min: 0, max: unboundedArgs}, + {name: "update", min: 0, max: unboundedArgs}, + {name: "sniff", min: 0, max: unboundedArgs}, + } + for _, test := range tests { + command, ok := lookupCommand(test.name) + if !ok { + t.Fatalf("command %q missing", test.name) + } + if command.MinArgs != test.min || command.MaxArgs != test.max { + t.Fatalf("%s arity = %d..%d, want %d..%d", test.name, command.MinArgs, command.MaxArgs, test.min, test.max) + } + } +} + +func TestCommandRunRejectsArityBeforeHandler(t *testing.T) { + called := false + command := commandDefinition{ + Name: "sample", + Usage: "sample ", + MinArgs: 1, + MaxArgs: 1, + Handler: func([]string) error { + called = true + return nil + }, + } + for _, args := range [][]string{nil, {"one", "two"}} { + if err := command.run(args); err == nil { + t.Fatalf("run(%v) succeeded", args) + } + if called { + t.Fatalf("handler called for invalid args %v", args) + } + } + if err := command.run([]string{"one"}); err != nil { + t.Fatalf("run valid args: %v", err) + } + if !called { + t.Fatal("handler not called for valid args") + } +} + func TestTopLevelHelpComesFromCommandRegistry(t *testing.T) { output, err := os.CreateTemp(t.TempDir(), "help-") if err != nil { diff --git a/cmd/init_subprocess_test.go b/cmd/init_subprocess_test.go new file mode 100644 index 0000000..547390e --- /dev/null +++ b/cmd/init_subprocess_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "compiler/pkg/manifest" + "compiler/pkg/peeper" +) + +func buildTestCLI(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), "peeper") + build := exec.Command("go", "build", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, output) + } + return binary +} + +func TestInitCommandCreatesRunnableNormalizedProject(t *testing.T) { + binary := buildTestCLI(t) + + t.Run("arity failure creates nothing", func(t *testing.T) { + root := t.TempDir() + command := exec.Command(binary, "init", "one", "two") + command.Dir = root + if output, err := command.CombinedOutput(); err == nil { + t.Fatalf("init with two names succeeded:\n%s", output) + } + for _, path := range []string{manifest.FileName, peeper.SourceDirName} { + if _, err := os.Lstat(filepath.Join(root, path)); !os.IsNotExist(err) { + t.Fatalf("invalid arity created %s: %v", path, err) + } + } + }) + + t.Run("normalized project runs", func(t *testing.T) { + root := t.TempDir() + command := exec.Command(binary, "init", "hello-peeper") + command.Dir = root + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("init failed: %v\n%s", err, output) + } + + file, err := manifest.Load(filepath.Join(root, manifest.FileName)) + if err != nil { + t.Fatalf("load generated manifest: %v", err) + } + if file.Package.Name != "hello_peeper" { + t.Fatalf("generated package name = %q, want hello_peeper", file.Package.Name) + } + manifestContent, err := os.ReadFile(filepath.Join(root, manifest.FileName)) + if err != nil { + t.Fatalf("read generated manifest: %v", err) + } + if !strings.Contains(string(manifestContent), "[dependencies]") { + t.Fatalf("generated manifest missing dependencies section:\n%s", manifestContent) + } + mainPath := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + starter, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("read generated starter: %v", err) + } + if !strings.Contains(string(starter), `println("Hello from Peeper!");`) { + t.Fatalf("generated starter missing semicolon:\n%s", starter) + } + + command = exec.Command(binary, "run") + command.Dir = root + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run generated project: %v\n%s", err, output) + } + if !strings.Contains(string(output), "Hello from Peeper!") { + t.Fatalf("generated project output:\n%s", output) + } + }) +} diff --git a/internal/driver/compiler.go b/internal/driver/compiler.go index 02a5005..1bc2794 100644 --- a/internal/driver/compiler.go +++ b/internal/driver/compiler.go @@ -11,8 +11,6 @@ import ( "compiler/internal/project" ) -const COMPILER_VERSION = "0.1.0" - // NewCompilerContext configures shared compiler state and loads the prelude. func NewCompilerContext(cfg project.Config, diag *diagnostics.DiagnosticBag) *project.CompilerContext { ctx := project.NewWithConfig(cfg, diag) diff --git a/internal/ir/hir/lower/lower_interface.go b/internal/ir/hir/lower/lower_interface.go index bf3a178..2f61af2 100644 --- a/internal/ir/hir/lower/lower_interface.go +++ b/internal/ir/hir/lower/lower_interface.go @@ -49,7 +49,7 @@ func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Modul InterfaceType: loweredTypeID(ctx, module, expectedType), MethodName: method.Name, SlotType: slotType, - FuncName: methodSymbolRefName(implementation.OwnerKey, implementation.Symbol), + FuncName: symbolName(module, implementation.Symbol), FuncType: loweredTypeID(ctx, module, implementation.CallableType), DataType: loweredTypeID(ctx, module, dataType), }) diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index 0ab5934..e079537 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -1,7 +1,9 @@ package lower import ( + "encoding/hex" "fmt" + "strconv" "strings" "compiler/internal/constvalue" @@ -47,20 +49,9 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu } fnType, _ := symbols.GetSymbolType(sym) resolvedFnType, _ := fnType.(*typeinfo.FuncType) - emittedName := sym.Name - if fn.Receiver != nil && resolvedFnType != nil && len(resolvedFnType.Params) > 0 { - if target, ok := typeinfo.ReceiverTarget(resolvedFnType.Params[0]); ok { - emittedName = methodFunctionName(typeinfo.TypeText(target), fn.Name.Name) - } - } + emittedName, _ := callableName(module, sym) if fn.Body == nil { - if fn.Receiver == nil { - emittedName = symbolName(sym) - } params, returnType := lowerExternSignature(ctx, module, sym.Scope.(*table.Scope), fn.ParamsWithReceiver(), fn.ReturnType, resolvedFnType) - if externName, ok := externSymbolName(sym, emittedName); ok { - emittedName = externName - } out.Externs = append(out.Externs, hir.Extern{ Name: emittedName, Params: params, @@ -138,7 +129,7 @@ func lowerASTFunctionNamed(ctx *project.CompilerContext, module *project.Module, if param.Name != nil { sym, ok := funcScope.LookupNode(param.Name) if ok && sym != nil { - name = symbolName(sym) + name = symbolName(module, sym) symbolID = sym.ID if t, ok := symbols.GetSymbolType(sym); ok { paramType = t @@ -197,7 +188,7 @@ func appendStmt(module *project.Module, scope *table.Scope, out *hir.Block, stmt out.Stmts = append(out.Stmts, &hir.ExprStmt{Value: valueExpr, NodeID: hir.NodeID(node.ID()), ValueNodeID: hir.NodeID(node.Value.ID()), Location: ast.LocOf(node)}) return } - out.Stmts = append(out.Stmts, &hir.Binding{Name: symbolName(sym), Constant: false, Type: loweredTypeID(ctx, module, sym.Type), Value: valueExpr, NodeID: hir.NodeID(node.ID()), SymbolID: sym.ID, Location: ast.LocOf(node)}) + out.Stmts = append(out.Stmts, &hir.Binding{Name: symbolName(module, sym), Constant: false, Type: loweredTypeID(ctx, module, sym.Type), Value: valueExpr, NodeID: hir.NodeID(node.ID()), SymbolID: sym.ID, Location: ast.LocOf(node)}) case *ast.ConstDecl: if node.Name == nil { @@ -217,7 +208,7 @@ func appendStmt(module *project.Module, scope *table.Scope, out *hir.Block, stmt out.Stmts = append(out.Stmts, &hir.ExprStmt{Value: valueExpr, NodeID: hir.NodeID(node.ID()), ValueNodeID: hir.NodeID(node.Value.ID()), Location: ast.LocOf(node)}) return } - out.Stmts = append(out.Stmts, &hir.Binding{Name: symbolName(sym), Constant: true, Type: loweredTypeID(ctx, module, sym.Type), Value: valueExpr, NodeID: hir.NodeID(node.ID()), SymbolID: sym.ID, Location: ast.LocOf(node)}) + out.Stmts = append(out.Stmts, &hir.Binding{Name: symbolName(module, sym), Constant: true, Type: loweredTypeID(ctx, module, sym.Type), Value: valueExpr, NodeID: hir.NodeID(node.ID()), SymbolID: sym.ID, Location: ast.LocOf(node)}) case *ast.IfStmt: condExpr := ir.Expr(&ir.InvalidExpr{Message: "invalid condition", Type: ir.InvalidType}) @@ -492,7 +483,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t t = ir.InvalidType } } - return &ir.Ident{Name: symbolName(sym), Type: t, SymbolID: sym.ID, Location: loc} + return &ir.Ident{Name: symbolName(module, sym), Type: t, SymbolID: sym.ID, Location: loc} case *ast.ScopeResolution: var sym *symbols.Symbol @@ -513,7 +504,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t t = ir.InvalidType } } - return &ir.Ident{Name: symbolName(sym), Type: t, SymbolID: sym.ID, Location: loc} + return &ir.Ident{Name: symbolName(module, sym), Type: t, SymbolID: sym.ID, Location: loc} } return &ir.InvalidExpr{Message: "unresolved qualified identifier: " + node.Module.Name + "::" + node.Name.Name, Type: ir.InvalidType, Location: loc} @@ -780,11 +771,9 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul if methodSym == nil || fnType == nil || len(fnType.Params) == 0 { return &ir.InvalidExpr{Message: "unsupported selector call lowering", Type: ir.InvalidType} } - methodOwner, ok := typeinfo.ReceiverTarget(fnType.Params[0]) - if !ok { + if _, ok := typeinfo.ReceiverTarget(fnType.Params[0]); !ok { return &ir.InvalidExpr{Message: "selector method receiver missing", Type: ir.InvalidType} } - methodOwnerKey := typeinfo.TypeText(methodOwner) var baseExpr ir.Expr if implicit := module.Semantics.ImplicitCallArguments[selector.Expr.ID()]; implicit != nil { baseExpr = lowerImplicitReferenceValue(ctx, module, scope, selector.Expr, implicit) @@ -802,7 +791,7 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul } return &ir.Call{ Callee: &ir.Ident{ - Name: methodSymbolRefName(methodOwnerKey, methodSym), + Name: symbolName(module, methodSym), Type: loweredTypeID(ctx, module, fnType), SymbolID: methodSym.ID, Location: ast.LocOf(selector.Name), @@ -990,22 +979,6 @@ func exprResolvedType(module *project.Module, expr ast.Expr) typeinfo.Type { return module.Semantics.ExprTypes[expr.ID()] } -func methodFunctionName(targetText, methodName string) string { - var b strings.Builder - b.WriteString("__impl__") - b.WriteString(ir.SanitizeSymbolName(targetText)) - b.WriteString("__") - b.WriteString(methodName) - return b.String() -} - -func methodSymbolRefName(targetText string, sym *symbols.Symbol) string { - if sym == nil { - return "" - } - return fmt.Sprintf("%s$%d", methodFunctionName(targetText, sym.Name), sym.ID) -} - func lowerNumberLit(ctx *project.CompilerContext, module *project.Module, node *ast.NumberLit, expectedType typeinfo.Type, loc *source.Location) ir.Expr { if node == nil { return &ir.InvalidExpr{Message: "nil number literal", Type: ir.InvalidType} @@ -1034,16 +1007,62 @@ func lowerNumberLit(ctx *project.CompilerContext, module *project.Module, node * return &ir.IntLit{Value: integerValue, Type: loweredTypeID(ctx, module, expectedType), Location: loc} } -func symbolName(sym *symbols.Symbol) string { +func symbolName(module *project.Module, sym *symbols.Symbol) string { if sym == nil { return "" } - if name, ok := externSymbolName(sym, sym.Name); ok { - return name + if sym.CompilerOp == "" && (sym.Kind == symbols.SymbolFunc || sym.Kind == symbols.SymbolMethod) { + name, external := callableName(module, sym) + if external { + return name + } + return fmt.Sprintf("%s$%d", name, sym.ID) } return fmt.Sprintf("%s$%d", sym.Name, sym.ID) } +func callableName(module *project.Module, sym *symbols.Symbol) (string, bool) { + if sym == nil || (sym.Kind != symbols.SymbolFunc && sym.Kind != symbols.SymbolMethod) { + return "", false + } + if fn, ok := sym.ASTNode.(*ast.FnDecl); ok { + if name, external := ast.FunctionLinkName(fn, sym.Name); external { + return name, true + } + } + if module != nil && module.IsEntry && sym.Kind == symbols.SymbolFunc && sym.Name == "main" && sym.DefiningModule == module.DefiningModuleKey() { + return "main", false + } + receiver := "" + if sym.Kind == symbols.SymbolMethod { + if typ, ok := symbols.GetSymbolType(sym); ok { + if fnType, ok := typ.(*typeinfo.FuncType); ok && fnType != nil && len(fnType.Params) > 0 { + if target, ok := typeinfo.ReceiverTarget(fnType.Params[0]); ok { + receiver = typeinfo.TypeText(target) + } + } + } + } + components := [...]string{ + sym.DefiningModule.Origin, + sym.DefiningModule.Namespace, + sym.DefiningModule.Dependency, + sym.DefiningModule.ImportPath, + string(sym.Kind), + sym.Name, + receiver, + } + var b strings.Builder + b.WriteString("__peeper_callable_") + for _, component := range components { + b.WriteString(strconv.Itoa(len(component))) + b.WriteByte('_') + b.WriteString(hex.EncodeToString([]byte(component))) + b.WriteByte('_') + } + return b.String(), false +} + func expandedDefaultBindingResolver(module *project.Module) place.BindingResolver { return func(ident *ast.Ident) (place.Binding, bool) { if module == nil || module.Semantics == nil || ident == nil { @@ -1056,17 +1075,6 @@ func expandedDefaultBindingResolver(module *project.Module) place.BindingResolve } } -func externSymbolName(sym *symbols.Symbol, defaultName string) (string, bool) { - if sym == nil { - return "", false - } - fn, ok := sym.ASTNode.(*ast.FnDecl) - if !ok { - return "", false - } - return ast.FunctionLinkName(fn, defaultName) -} - func shouldDiscardBindingValue(sym *symbols.Symbol) bool { if sym == nil || sym.Used { return false diff --git a/internal/ir/hir/lower/module_lower_test.go b/internal/ir/hir/lower/module_lower_test.go index f24b3ab..a1cbf02 100644 --- a/internal/ir/hir/lower/module_lower_test.go +++ b/internal/ir/hir/lower/module_lower_test.go @@ -1,6 +1,8 @@ package lower import ( + "fmt" + "strings" "testing" "compiler/internal/diagnostics" @@ -27,6 +29,7 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower Key: project.ModuleKeyFor(project.ModuleOriginLocal, filePath), ImportPath: importPath, FilePath: filePath, + IsEntry: true, Content: src, AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), Imports: make(map[string]project.ResolvedImport), @@ -46,6 +49,58 @@ func generateTestHIR(t *testing.T, filePath, importPath, src string, beforeLower return out } +func TestGenerateHIRCallableNamesAreStableAndModuleAware(t *testing.T) { + const src = `struct Counter { value: i32 } +fn Value() -> i32 { return 1; } +fn (self: Counter) Read() -> i32 { return self.value; }` + first := generateTestHIR(t, "first"+peeper.SourceExt, "sample/first", src) + repeated := generateTestHIR(t, "first"+peeper.SourceExt, "sample/first", src) + second := generateTestHIR(t, "second"+peeper.SourceExt, "sample/second", src) + if len(first.Funcs) != 2 || len(repeated.Funcs) != 2 || len(second.Funcs) != 2 { + t.Fatalf("unexpected function counts: %d, %d, %d", len(first.Funcs), len(repeated.Funcs), len(second.Funcs)) + } + for index := range first.Funcs { + if first.Funcs[index].Name != repeated.Funcs[index].Name { + t.Fatalf("callable name is not deterministic: %q != %q", first.Funcs[index].Name, repeated.Funcs[index].Name) + } + if first.Funcs[index].Name == second.Funcs[index].Name { + t.Fatalf("module callables collide at index %d: %q", index, first.Funcs[index].Name) + } + if strings.Contains(first.Funcs[index].Name, "$") { + t.Fatalf("callable linker name uses local instance delimiter: %q", first.Funcs[index].Name) + } + } +} + +func TestCallableNameFramesModuleIdentityComponents(t *testing.T) { + first := symbols.New("Value", symbols.SymbolFunc, nil, nil) + first.DefiningModule = symbols.DefiningModuleKey{Origin: "local", Namespace: "ab", Dependency: "c", ImportPath: "sample/value"} + second := symbols.New("Value", symbols.SymbolFunc, nil, nil) + second.DefiningModule = symbols.DefiningModuleKey{Origin: "local", Namespace: "a", Dependency: "bc", ImportPath: "sample/value"} + firstName, _ := callableName(nil, first) + secondName, _ := callableName(nil, second) + if firstName == secondName { + t.Fatalf("length-ambiguous module identities collide: %q", firstName) + } +} + +func TestSymbolNameLeavesCompilerOwnedFunctionUnmangled(t *testing.T) { + sym := symbols.New("alloc", symbols.SymbolFunc, nil, nil) + sym.CompilerOp = symbols.CompilerOpAlloc + want := fmt.Sprintf("alloc$%d", sym.ID) + if got := symbolName(nil, sym); got != want { + t.Fatalf("compiler-owned symbol name = %q, want %q", got, want) + } +} + +func TestGenerateHIRPreservesExternLinkName(t *testing.T) { + out := generateTestHIR(t, "extern_name"+peeper.SourceExt, "sample/extern", `#[extern("native_ping")] +fn ping() -> i32;`) + if len(out.Externs) != 1 || out.Externs[0].Name != "native_ping" { + t.Fatalf("extern name = %#v, want native_ping", out.Externs) + } +} + func TestGenerateHIRLowersIndexExpr(t *testing.T) { const filePath = "hir_index_test" + peeper.SourceExt src := `fn first(xs: [4]i32) -> i32 { @@ -467,12 +522,11 @@ fn nested(mut bucket: Bucket) { let _ = &mut bucket.items; }` out := generateTestHIR(t, filePath, "hir_slice_view_test", src) - funcs := make(map[string]*hir.Function, len(out.Funcs)) - for _, fn := range out.Funcs { - funcs[fn.Name] = fn + if len(out.Funcs) != 3 { + t.Fatalf("unexpected function count: %d", len(out.Funcs)) } - explicit := funcs["explicit"] + explicit := out.Funcs[0] if explicit == nil || explicit.Body == nil || len(explicit.Body.Stmts) < 1 { t.Fatalf("unexpected explicit borrow HIR: %#v", explicit) } @@ -484,7 +538,7 @@ fn nested(mut bucket: Bucket) { t.Fatalf("expected shared owner reference, got %#v", binding.Value) } - explicitMutable := funcs["explicit_mutable"] + explicitMutable := out.Funcs[1] if explicitMutable == nil || explicitMutable.Body == nil || len(explicitMutable.Body.Stmts) < 1 { t.Fatalf("unexpected explicit mutable borrow HIR: %#v", explicitMutable) } @@ -496,7 +550,7 @@ fn nested(mut bucket: Bucket) { t.Fatalf("expected mutable owner reference, got %#v", binding.Value) } - nested := funcs["nested"] + nested := out.Funcs[2] if nested == nil || nested.Body == nil || len(nested.Body.Stmts) < 1 { t.Fatalf("unexpected nested mutable borrow HIR: %#v", nested) } @@ -519,12 +573,11 @@ func TestGenerateHIRLowersAddressAsOpaqueRawPointer(t *testing.T) { let _ = @value; }` out := generateTestHIR(t, filePath, "hir_raw_pointer_address_test", src) - funcs := make(map[string]*hir.Function, len(out.Funcs)) - for _, fn := range out.Funcs { - funcs[fn.Name] = fn + if len(out.Funcs) != 1 { + t.Fatalf("unexpected function count: %d", len(out.Funcs)) } - explicit := funcs["explicit"] + explicit := out.Funcs[0] if explicit == nil || explicit.Body == nil || len(explicit.Body.Stmts) != 1 { t.Fatalf("unexpected explicit raw pointer HIR: %#v", explicit) } @@ -589,13 +642,10 @@ fn consume(counter: *Counter) -> i32 { return consumer.take(); }` out := generateTestHIR(t, "hir_consuming_interface_test"+peeper.SourceExt, "hir_consuming_interface_test", src) - var consume *hir.Function - for _, fn := range out.Funcs { - if fn.Name == "consume" { - consume = fn - break - } + if len(out.Funcs) != 2 { + t.Fatalf("unexpected function count: %d", len(out.Funcs)) } + consume := out.Funcs[1] if consume == nil || consume.Body == nil || len(consume.Body.Stmts) != 2 { t.Fatalf("unexpected consuming interface HIR: %#v", consume) } @@ -667,6 +717,9 @@ fn main() -> i32 { if !ok || len(carrier.Slots) != 1 || carrier.Slots[0].MethodName != "read" { t.Fatalf("interface carrier = %#v, want recorded read slot", binding.Value) } + if ir.StripSymbolInstance(carrier.Slots[0].FuncName) != out.Funcs[0].Name { + t.Fatalf("interface slot target %q does not name method definition %q", carrier.Slots[0].FuncName, out.Funcs[0].Name) + } } func TestGenerateHIRConsumesDistinctDefaultInterfaceEvidence(t *testing.T) { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index ae01b44..ed61de5 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -16,6 +16,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" + "compiler/pkg/manifest" "compiler/pkg/peeper" ) @@ -1285,6 +1286,39 @@ func TestLSPInitializedPublishesDiagnosticsForUnopenedWorkspaceFiles(t *testing. } } +func TestManifestLoadFailuresPublishOnSourceURI(t *testing.T) { + tests := []struct { + name string + manifest string + want string + }{ + {name: "malformed", manifest: "not valid toml", want: "parse manifest"}, + {name: "incompatible", manifest: "name = \"app\"\ncompiler = \">=0.2.0\"\nbuild = \"program\"\n", want: "requires compiler"}, + {name: "compatible", manifest: "name = \"app\"\ncompiler = \"<=0.1.0\"\nbuild = \"program\"\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + mainPath := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + writeWorkspaceFile(t, filepath.Join(root, manifest.FileName), test.manifest) + writeWorkspaceFile(t, mainPath, "fn main() {}\n") + + state := NewServerState() + state.RootDir = root + published := publishCurrentDiagnostics(t, state, mainPath) + if string(published.URI) != pathToURI(mainPath) { + t.Fatalf("diagnostic URI = %q, want %q", published.URI, pathToURI(mainPath)) + } + if test.want == "" && len(published.Diagnostics) != 0 { + t.Fatalf("compatible manifest diagnostics = %#v, want none", published.Diagnostics) + } + if test.want != "" && (len(published.Diagnostics) != 1 || !strings.Contains(published.Diagnostics[0].Message, test.want)) { + t.Fatalf("diagnostics = %#v, want one containing %q", published.Diagnostics, test.want) + } + }) + } +} + func TestLSPDidChangeClearsDiagnosticsForFixedComponentFile(t *testing.T) { root := t.TempDir() writeWorkspaceProjectConfig(t, root, "app") diff --git a/internal/lsp/state.go b/internal/lsp/state.go index 2bfa35d..c4d0b44 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -149,9 +149,9 @@ func (s *ServerState) recompileLocked(entryFile string) (*project.CompilerContex ctx := compiler.NewCompilerContext(cfg, diagBag) ctx.Metrics = &project.CompileMetrics{} if err != nil { - ctx.Diagnostics.BeginPhase(phase.Load, "").Add(diagnostics.NewError( - err.Error(), - )) + diagnostic := diagnostics.NewError(err.Error()) + diagnostic.FilePath = canonicalEntry + ctx.Diagnostics.BeginPhase(phase.Load, "").Add(diagnostic) s.LastCtx = ctx s.LastMetrics = ctx.Metrics.Snapshot() return ctx, nil diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index c85e03c..0848c19 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -45,6 +45,7 @@ func (p *Pipeline) Run(entry *project.Module) error { return errors.New("empty pipeline") } + entry.IsEntry = true p.ctx.AddModule(entry) p.ctx.CompletedProjectPhase = phase.Load diag := p.ctx.Diagnostics diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 1d4ff79..92b4de4 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -256,12 +256,15 @@ fn BadMalloc(size: Word) -> rawptr; #[extern("free")] fn BadFree(value: Word);`) out := diag.EmitAllToString() - for _, symbol := range []string{"printf", "malloc", "free"} { + for _, symbol := range []string{"malloc", "free"} { message := "runtime symbol `" + symbol + "`" if count := strings.Count(out, message); count != 1 { t.Fatalf("expected one %s reservation diagnostic, got %d:\n%s", symbol, count, out) } } + if strings.Contains(out, "runtime symbol `printf`") { + t.Fatalf("module-mangled printf must not conflict with runtime symbol:\n%s", out) + } } func TestPipelineExternWithoutSymbolOverrideUsesDeclaredName(t *testing.T) { diff --git a/internal/project/modules.go b/internal/project/modules.go index f8eec78..8fdc4ee 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -119,6 +119,18 @@ type InterfaceImplementation struct { OwnerKey string } +func (m *Module) DefiningModuleKey() symbols.DefiningModuleKey { + if m == nil { + return symbols.DefiningModuleKey{} + } + return symbols.DefiningModuleKey{ + Origin: string(m.Origin), + Namespace: m.Namespace, + Dependency: m.Dependency, + ImportPath: m.ImportPath, + } +} + func NewSemanticInfo() *SemanticInfo { return &SemanticInfo{ BlockScopes: make(map[ast.NodeID]*table.Scope), diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index a3c62c2..75e5727 100644 --- a/internal/semantics/collector/collector.go +++ b/internal/semantics/collector/collector.go @@ -88,12 +88,14 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { return } sym := symbols.New(fn.Name.Name, symbols.SymbolMethod, fn, ast.LocOf(fn.Name)) + sym.DefiningModule = c.module.DefiningModuleKey() sym.Scope = table.New(c.module.ModuleScope) c.module.Semantics.MethodSets[targetKey] = append(c.module.Semantics.MethodSets[targetKey], sym) c.module.Semantics.MethodSymbol[fn.ID()] = sym return } sym := symbols.New(fn.Name.Name, symbols.SymbolFunc, fn, ast.LocOf(fn.Name)) + sym.DefiningModule = c.module.DefiningModuleKey() sym.Scope = table.New(c.module.ModuleScope) if err := c.module.ModuleScope.Declare(sym); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, c.module.ModuleScope, err.Error(), fn.Name.Name, fn.Name.Location) diff --git a/internal/semantics/collector/collector_test.go b/internal/semantics/collector/collector_test.go index 14e6cdd..eaa7cdf 100644 --- a/internal/semantics/collector/collector_test.go +++ b/internal/semantics/collector/collector_test.go @@ -8,9 +8,46 @@ import ( "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/project" + "compiler/internal/semantics/symbols" "compiler/pkg/peeper" ) +func TestCallableSymbolsKeepDefiningModuleKey(t *testing.T) { + const filePath = "collector_callable_module_test" + peeper.SourceExt + const src = `struct Counter { value: i32 } +fn Value() -> i32 { return 1; } +fn (self: Counter) Read() -> i32 { return self.value; }` + diag := diagnostics.NewDiagnosticBag() + module := &project.Module{ + Key: project.ModuleKeyFor(project.ModuleOriginDependency, filePath), + ImportPath: "math/counter", + FilePath: filePath, + Namespace: "vendor", + Origin: project.ModuleOriginDependency, + Dependency: "mathlib", + Content: src, + AST: parser.New(filePath, lexer.New(filePath, src, diag).Tokenize(), diag).ParseModule(), + Imports: make(map[string]project.ResolvedImport), + } + ctx := project.New(".", peeper.SourceExt, diag) + Collect(ctx, module) + + want := symbols.DefiningModuleKey{ + Origin: string(project.ModuleOriginDependency), + Namespace: "vendor", + Dependency: "mathlib", + ImportPath: "math/counter", + } + function, ok := module.ModuleScope.LookupLocal("Value") + if !ok || function == nil || function.DefiningModule != want { + t.Fatalf("function defining module = %#v, want %#v", function, want) + } + methods := module.Semantics.MethodSets["Counter"] + if len(methods) != 1 || methods[0] == nil || methods[0].DefiningModule != want { + t.Fatalf("method defining module = %#v, want %#v", methods, want) + } +} + func TestImportSymbolsKeepSourceLocation(t *testing.T) { const filePath = "collector_import_test" + peeper.SourceExt src := `import "external"; diff --git a/internal/semantics/symbols/symbol.go b/internal/semantics/symbols/symbol.go index 1d73abe..7111bd6 100644 --- a/internal/semantics/symbols/symbol.go +++ b/internal/semantics/symbols/symbol.go @@ -48,20 +48,28 @@ type Type interface { Text() string } +type DefiningModuleKey struct { + Origin string + Namespace string + Dependency string + ImportPath string +} + type Symbol struct { - ID SymbolID - Name string - Kind Kind - Type Type - IsPub bool - Mutable bool - IsReceiver bool - Initializing bool - Used bool - CompilerOp CompilerOp - Location *source.Location - ASTNode ast.Node - Scope any // Pointer to table.Scope (only if Kind == SymbolFunc) + ID SymbolID + Name string + Kind Kind + Type Type + IsPub bool + Mutable bool + IsReceiver bool + Initializing bool + Used bool + CompilerOp CompilerOp + DefiningModule DefiningModuleKey + Location *source.Location + ASTNode ast.Node + Scope any // Pointer to table.Scope (only if Kind == SymbolFunc) } func New(name string, kind Kind, node ast.Node, location *source.Location) *Symbol { diff --git a/pkg/manifest/lockfile.go b/pkg/manifest/lockfile.go index 755ce07..fcff1d6 100644 --- a/pkg/manifest/lockfile.go +++ b/pkg/manifest/lockfile.go @@ -1,8 +1,12 @@ package manifest import ( + "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "maps" "os" "path/filepath" @@ -13,9 +17,9 @@ import ( ) const ( - LockfileName = "peeper.lock" - lockfileVersion = "1.0" - lockfileCurrentVer = lockfileVersion + LockfileName = "peeper.lock" + lockfileLegacyVersion = "1.0" + lockfileCurrentVersion = "2.0" ) type LockfileEntry struct { @@ -39,7 +43,7 @@ type Lockfile struct { func NewLockfile() *Lockfile { return &Lockfile{ - Version: lockfileCurrentVer, + Version: lockfileCurrentVersion, DirectDeps: map[string]string{}, Packages: map[string]LockfileEntry{}, Dependencies: map[string]LockfileEntry{}, @@ -57,63 +61,126 @@ func LoadLockfile(projectRoot string) (*Lockfile, error) { return nil, fmt.Errorf("read lockfile: %w", err) } - raw, err := parseRawLockfile(data) + lock, err := parseLockfile(data) if err != nil { return nil, err } - - lock := &Lockfile{ - Version: raw.Version, - DirectDeps: raw.DirectDeps, - Packages: normalizePackageEntries(raw.Packages), - Dependencies: normalizePackageEntries(raw.Dependencies), - GeneratedAt: raw.GeneratedAt, + if err := validateLockfileChecksums(lock); err != nil { + return nil, err } normalizeLockfileShape(lock) return lock, nil } -// parseRawLockfile reads a lockfile from disk and decodes its raw structure. -func parseRawLockfile(data []byte) (*rawLockfile, error) { - type rawLockfileJSON struct { +func ValidateLockfileChecksum(checksum string) error { + if checksum == "" { + return nil + } + const prefix = "sha256:" + encoded, ok := strings.CutPrefix(checksum, prefix) + if !ok || len(encoded) != sha256.Size*2 { + return fmt.Errorf("expected sha256:<64 hex characters>") + } + if _, err := hex.DecodeString(encoded); err != nil { + return fmt.Errorf("expected sha256:<64 hex characters>: %w", err) + } + return nil +} + +func validateLockfileChecksums(lock *Lockfile) error { + for _, entries := range []map[string]LockfileEntry{lock.Packages, lock.Dependencies} { + for packageID, entry := range entries { + if err := ValidateLockfileChecksum(entry.Checksum); err != nil { + return fmt.Errorf("lockfile package %q checksum: %w", packageID, err) + } + } + } + return nil +} + +func parseLockfile(data []byte) (*Lockfile, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("parse lockfile: %w", err) + } + if envelope == nil { + return nil, fmt.Errorf("parse lockfile: expected object") + } + version := "" + if rawVersion, ok := envelope["version"]; ok { + if err := json.Unmarshal(rawVersion, &version); err != nil { + return nil, fmt.Errorf("parse lockfile version: %w", err) + } + if bytes.Equal(bytes.TrimSpace(rawVersion), []byte("null")) { + return nil, fmt.Errorf("parse lockfile version: expected string") + } + } + if version != "" && version != lockfileLegacyVersion && version != lockfileCurrentVersion { + return nil, fmt.Errorf("unsupported lockfile version %q", version) + } + + type legacyLockfile struct { Version string `json:"version"` - DirectDeps json.RawMessage `json:"direct_deps"` - Packages map[string]LockfileEntry `json:"packages"` + DirectDeps []string `json:"direct_deps"` Dependencies map[string]LockfileEntry `json:"dependencies"` GeneratedAt string `json:"generated_at,omitempty"` } - var raw rawLockfileJSON - if err := json.Unmarshal(data, &raw); err != nil { + type currentLockfile struct { + Version string `json:"version"` + DirectDeps map[string]string `json:"direct_deps"` + Packages map[string]LockfileEntry `json:"packages"` + GeneratedAt string `json:"generated_at,omitempty"` + } + + _, hasPackages := envelope["packages"] + if version == lockfileCurrentVersion || (version == lockfileLegacyVersion && hasPackages) { + var raw currentLockfile + if err := decodeStrictJSON(data, &raw); err != nil { + return nil, fmt.Errorf("parse lockfile: %w", err) + } + return &Lockfile{ + Version: lockfileCurrentVersion, + DirectDeps: raw.DirectDeps, + Packages: normalizePackageEntries(raw.Packages), + GeneratedAt: raw.GeneratedAt, + }, nil + } + + var raw legacyLockfile + if err := decodeStrictJSON(data, &raw); err != nil { return nil, fmt.Errorf("parse lockfile: %w", err) } - directDeps, err := decodeDirectDeps(raw.DirectDeps) - if err != nil { - return nil, fmt.Errorf("parse lockfile direct_deps: %w", err) + directDeps := make(map[string]string, len(raw.DirectDeps)) + for _, dependency := range raw.DirectDeps { + directDeps[dependency] = dependency } - return &rawLockfile{ - Version: raw.Version, + return &Lockfile{ + Version: lockfileCurrentVersion, DirectDeps: directDeps, - Packages: raw.Packages, - Dependencies: raw.Dependencies, + Dependencies: normalizePackageEntries(raw.Dependencies), GeneratedAt: raw.GeneratedAt, }, nil } -// rawLockfile holds the decoded lockfile data before normalization. -type rawLockfile struct { - Version string - DirectDeps map[string]string - Packages map[string]LockfileEntry - Dependencies map[string]LockfileEntry - GeneratedAt string +func decodeStrictJSON(data []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values") + } + return err + } + return nil } -// normalizeLockfileShape applies version defaults, map-nil guards, +// normalizeLockfileShape applies the current version, map-nil guards, // and the Packages/Dependencies sync rules used by both Load and Save. func normalizeLockfileShape(lock *Lockfile) { - if lock.Version == "" || lock.Version == lockfileVersion { - lock.Version = lockfileCurrentVer - } + lock.Version = lockfileCurrentVersion if lock.Packages == nil { lock.Packages = map[string]LockfileEntry{} } @@ -139,7 +206,7 @@ func SaveLockfile(projectRoot string, lock *Lockfile) error { return err } path := filepath.Join(projectRoot, LockfileName) - return writeFileAtomic(path, data, 0o644) + return WriteFileAtomic(path, data, 0o644) } func marshalLockfile(lock *Lockfile) ([]byte, error) { @@ -150,9 +217,9 @@ func marshalLockfile(lock *Lockfile) ([]byte, error) { lock.GeneratedAt = time.Now().Format(time.RFC3339) out := &Lockfile{ - Version: lock.Version, - DirectDeps: sortStringMap(lock.DirectDeps), - Packages: sortEntriesByKey(lock.Packages), + Version: lockfileCurrentVersion, + DirectDeps: lock.DirectDeps, + Packages: lock.Packages, GeneratedAt: lock.GeneratedAt, } @@ -163,36 +230,6 @@ func marshalLockfile(lock *Lockfile) ([]byte, error) { return data, nil } -// sortEntriesByKey returns a new map whose iteration order is alphabetical by key. -// Used for deterministic JSON output. -func sortEntriesByKey(entries map[string]LockfileEntry) map[string]LockfileEntry { - keys := sortedKeys(entries) - sorted := make(map[string]LockfileEntry, len(entries)) - for _, key := range keys { - sorted[key] = entries[key] - } - return sorted -} - -// sortStringMap returns a new map whose iteration order is alphabetical by key. -func sortStringMap(m map[string]string) map[string]string { - keys := sortedKeys(m) - sorted := make(map[string]string, len(m)) - for _, key := range keys { - sorted[key] = m[key] - } - return sorted -} - -func sortedKeys[V any](m map[string]V) []string { - keys := make([]string, 0, len(m)) - for key := range m { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - func (l *Lockfile) SetDependency(key string, entry LockfileEntry) { if l == nil { return @@ -440,27 +477,6 @@ func uniqueStrings(values []string) []string { return out } -func decodeDirectDeps(raw json.RawMessage) (map[string]string, error) { - if len(raw) == 0 || string(raw) == "null" { - return map[string]string{}, nil - } - - var byAlias map[string]string - if err := json.Unmarshal(raw, &byAlias); err == nil { - return byAlias, nil - } - - var asList []string - if err := json.Unmarshal(raw, &asList); err == nil { - converted := make(map[string]string, len(asList)) - for _, dep := range asList { - converted[dep] = dep - } - return converted, nil - } - return nil, fmt.Errorf("expected object or array") -} - func copyEntries(src map[string]LockfileEntry) map[string]LockfileEntry { if src == nil { return nil @@ -489,6 +505,9 @@ func normalizePackageEntries(src map[string]LockfileEntry) map[string]LockfileEn if entry.ResolvedURL == "" { entry.ResolvedURL = repoFromPackageKey(normalizedKey) } + if encoded, ok := strings.CutPrefix(entry.Checksum, "sha256:"); ok { + entry.Checksum = "sha256:" + strings.ToLower(encoded) + } dst[normalizedKey] = entry } return dst diff --git a/pkg/manifest/lockfile_test.go b/pkg/manifest/lockfile_test.go index bf0e48f..e0f0f6c 100644 --- a/pkg/manifest/lockfile_test.go +++ b/pkg/manifest/lockfile_test.go @@ -1,16 +1,35 @@ package manifest import ( + "bytes" "os" "path/filepath" "strings" "testing" ) -func TestLoadLockfileMigratesV1Shape(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, LockfileName) - content := `{ +func TestLoadLockfileMigratesLegacyShapes(t *testing.T) { + tests := []struct { + name string + content string + alias string + }{ + { + name: "missing version with dependencies", + content: `{ + "direct_deps": ["github.com/acme/json"], + "dependencies": { + "github.com/acme/json": { + "version": "v1.2.3", + "direct": true + } + } +}`, + alias: "github.com/acme/json", + }, + { + name: "version 1 with dependencies", + content: `{ "version": "1.0", "direct_deps": ["github.com/acme/json"], "dependencies": { @@ -19,20 +38,48 @@ func TestLoadLockfileMigratesV1Shape(t *testing.T) { "direct": true } } -}` - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) +}`, + alias: "github.com/acme/json", + }, + { + name: "mislabelled version 1 with packages", + content: `{ + "version": "1.0", + "direct_deps": { + "json": "github.com/acme/json@v1.2.3" + }, + "packages": { + "github.com/acme/json@v1.2.3": { + "version": "v1.2.3", + "direct": true + } + } +}`, + alias: "json", + }, } - lock, err := LoadLockfile(root) - if err != nil { - t.Fatalf("load lockfile: %v", err) - } - if got := lock.DirectDeps["github.com/acme/json"]; got != "github.com/acme/json@v1.2.3" { - t.Fatalf("expected migrated direct dep mapping, got %q", got) - } - if _, ok := lock.Packages["github.com/acme/json@v1.2.3"]; !ok { - t.Fatalf("expected migrated package entry") + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, LockfileName), []byte(test.content), 0o644); err != nil { + t.Fatal(err) + } + + lock, err := LoadLockfile(root) + if err != nil { + t.Fatalf("load lockfile: %v", err) + } + if lock.Version != "2.0" { + t.Fatalf("migrated version = %q, want 2.0", lock.Version) + } + if got := lock.DirectDeps[test.alias]; got != "github.com/acme/json@v1.2.3" { + t.Fatalf("migrated direct dep = %q", got) + } + if _, ok := lock.Packages["github.com/acme/json@v1.2.3"]; !ok { + t.Fatal("expected migrated package entry") + } + }) } } @@ -85,6 +132,9 @@ func TestSaveLockfileOmitsLegacyDependenciesField(t *testing.T) { t.Fatal(err) } text := string(data) + if !strings.Contains(text, `"version": "2.0"`) { + t.Fatalf("expected v2 lockfile:\n%s", text) + } if strings.Contains(text, `"dependencies"`) { t.Fatalf("expected saved lockfile to omit legacy dependencies field:\n%s", text) } @@ -93,6 +143,79 @@ func TestSaveLockfileOmitsLegacyDependenciesField(t *testing.T) { } } +func TestLoadLockfileRejectsUnsupportedVersionWithoutRewrite(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, LockfileName) + content := []byte(`{"version":"3.0","packages":{}}`) + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + + if _, err := LoadLockfile(root); err == nil || !strings.Contains(err.Error(), "unsupported lockfile version") { + t.Fatalf("LoadLockfile error = %v, want unsupported version", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, content) { + t.Fatalf("unsupported lockfile changed: %q", after) + } +} + +func TestLoadLockfileRejectsInvalidSupportedSchemas(t *testing.T) { + tests := []struct { + name string + content string + }{ + {name: "null document", content: `null`}, + {name: "v2 legacy fields", content: `{"version":"2.0","direct_deps":[],"dependencies":{}}`}, + {name: "v2 top-level unknown field", content: `{"version":"2.0","packages":{},"extra":true}`}, + {name: "v2 package unknown field", content: `{"version":"2.0","packages":{"repo@v1":{"version":"v1","extra":true}}}`}, + {name: "v1 top-level unknown field", content: `{"version":"1.0","dependencies":{},"extra":true}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, LockfileName), []byte(test.content), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadLockfile(root); err == nil { + t.Fatal("LoadLockfile accepted invalid schema") + } + }) + } +} + +func TestLoadLockfileRejectsInvalidChecksum(t *testing.T) { + root := t.TempDir() + content := `{"version":"2.0","packages":{"repo@v1":{"version":"v1","checksum":"sha256:not-hex"}}}` + if err := os.WriteFile(filepath.Join(root, LockfileName), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadLockfile(root); err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("LoadLockfile checksum error = %v", err) + } +} + +func TestLoadLockfileNormalizesUppercaseChecksumHex(t *testing.T) { + root := t.TempDir() + checksum := "sha256:" + strings.Repeat("A", 64) + content := `{"version":"2.0","packages":{"repo@v1":{"version":"v1","checksum":"` + checksum + `"}}}` + if err := os.WriteFile(filepath.Join(root, LockfileName), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + lock, err := LoadLockfile(root) + if err != nil { + t.Fatal(err) + } + entry, ok := lock.GetDependency("repo@v1") + if !ok || entry.Checksum != strings.ToLower(checksum) { + t.Fatalf("normalized checksum = %q", entry.Checksum) + } +} + func TestSetDirectDependencyDemotesPreviousVersion(t *testing.T) { lock := NewLockfile() lock.SetDependency("github.com/acme/json@v1.0.0", LockfileEntry{ @@ -353,11 +476,26 @@ func TestFilterOutNoMatch(t *testing.T) { } } -func TestSortedKeysDeterministicOrder(t *testing.T) { - m := map[string]int{"c": 3, "a": 1, "b": 2} - got := sortedKeys(m) - if !stringSlicesEqual(got, []string{"a", "b", "c"}) { - t.Errorf("sortedKeys() = %v, want [a b c]", got) +func TestMarshalLockfileOrdersMapKeysDeterministically(t *testing.T) { + lock := NewLockfile() + lock.DirectDeps = map[string]string{"z": "z@v1", "a": "a@v1"} + lock.SetDependency("z@v1", LockfileEntry{Version: "v1"}) + lock.SetDependency("a@v1", LockfileEntry{Version: "v1"}) + + data, err := marshalLockfile(lock) + if err != nil { + t.Fatal(err) + } + text := string(data) + directA := strings.Index(text, `"a": "a@v1"`) + directZ := strings.Index(text, `"z": "z@v1"`) + if directA < 0 || directZ < 0 || directA > directZ { + t.Fatalf("direct dependency keys are not sorted:\n%s", text) + } + packageA := strings.Index(text, `"a@v1": {`) + packageZ := strings.Index(text, `"z@v1": {`) + if packageA < 0 || packageZ < 0 || packageA > packageZ { + t.Fatalf("package keys are not sorted:\n%s", text) } } diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 2f85510..9b6d8f8 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -1,6 +1,7 @@ package manifest import ( + "errors" "fmt" "os" "path/filepath" @@ -22,6 +23,9 @@ const ( reservedStdAlias = "core" ) +// ErrManifestNotFound identifies manifest discovery ending without a manifest. +var ErrManifestNotFound = errors.New("no peeper.toml found") + func CacheModulesDir(projectRoot string) string { return filepath.Join(projectRoot, cacheDirName, cacheModulesSubdir) } @@ -84,19 +88,27 @@ var ( func FindManifestPath(startDir string) (string, error) { dir, err := filepath.Abs(startDir) if err != nil { - return "", err + return "", fmt.Errorf("resolve manifest search path %q: %w", startDir, err) } if info, statErr := os.Stat(dir); statErr == nil && !info.IsDir() { dir = filepath.Dir(dir) + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return "", fmt.Errorf("stat manifest search path %q: %w", dir, statErr) } for { manifestPath := filepath.Join(dir, FileName) - if _, err := os.Stat(manifestPath); err == nil { + if _, statErr := os.Stat(manifestPath); statErr == nil { return manifestPath, nil + } else if !errors.Is(statErr, os.ErrNotExist) { + return "", fmt.Errorf("stat manifest %q: %w", manifestPath, statErr) + } else if _, linkErr := os.Lstat(manifestPath); linkErr == nil { + return "", fmt.Errorf("stat manifest %q: %w", manifestPath, statErr) + } else if !errors.Is(linkErr, os.ErrNotExist) { + return "", fmt.Errorf("inspect manifest %q: %w", manifestPath, linkErr) } parent := filepath.Dir(dir) if parent == dir { - return "", fmt.Errorf("no %s found", FileName) + return "", fmt.Errorf("%w from %q", ErrManifestNotFound, startDir) } dir = parent } @@ -134,7 +146,7 @@ func LoadProject(startPath string) (*Project, error) { } file, err := Load(manifestPath) if err != nil { - return nil, err + return nil, fmt.Errorf("%s: %w", manifestPath, err) } return &Project{ RootDir: filepath.Dir(manifestPath), @@ -150,7 +162,10 @@ func ResolveSourceFileProject(path string) (SourceFileProject, error) { loadedProject, err := LoadProject(path) if err != nil { - return ctx, nil + if errors.Is(err, ErrManifestNotFound) { + return ctx, nil + } + return ctx, err } ctx.RootDir = loadedProject.RootDir @@ -179,8 +194,8 @@ func Load(path string) (*File, error) { if err != nil { return nil, err } - if !identifierPattern.MatchString(name) { - return nil, fmt.Errorf("invalid package.name %q", name) + if err := ValidatePackageName(name); err != nil { + return nil, err } manifest.Package.Name = name if version, ok, err := toml.LookupKey[string](pkg, "version"); err != nil { @@ -194,6 +209,15 @@ func Load(path string) (*File, error) { if compilerVersion, ok, err := toml.LookupKey[string](pkg, "compiler"); err != nil { return nil, fmt.Errorf("compiler: %w", err) } else if ok { + if strings.TrimSpace(compilerVersion) != "" { + matches, err := semver.Match(peeper.CompilerVersion, compilerVersion) + if err != nil { + return nil, fmt.Errorf("invalid compiler constraint %q: %w", compilerVersion, err) + } + if !matches { + return nil, fmt.Errorf("manifest requires compiler %q, current compiler is %s", compilerVersion, peeper.CompilerVersion) + } + } manifest.Package.CompilerVersion = compilerVersion } build, ok, err := toml.LookupKey[string](pkg, "build") @@ -238,6 +262,14 @@ func Load(path string) (*File, error) { return manifest, nil } +// ValidatePackageName applies package identifier rules shared by manifest loading and project creation. +func ValidatePackageName(name string) error { + if !identifierPattern.MatchString(name) { + return fmt.Errorf("invalid package.name %q", name) + } + return nil +} + func ParseDependency(raw toml.Value) (Dependency, error) { switch value := raw.(type) { case string: @@ -332,7 +364,7 @@ func Save(path string, file *File) error { if err != nil { return err } - return writeFileAtomic(path, data, 0o644) + return WriteFileAtomic(path, data, 0o644) } func marshalManifest(file *File) ([]byte, error) { diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index 6f0fc7a..9d6fb95 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -1,6 +1,7 @@ package manifest import ( + "errors" "fmt" "os" "path/filepath" @@ -10,6 +11,92 @@ import ( "compiler/pkg/peeper" ) +func TestFindManifestPathClassifiesOnlyAbsence(t *testing.T) { + root := t.TempDir() + if _, err := FindManifestPath(root); !errors.Is(err, ErrManifestNotFound) { + t.Fatalf("FindManifestPath missing error = %v, want ErrManifestNotFound", err) + } + + manifestPath := filepath.Join(root, FileName) + if err := os.Symlink(FileName, manifestPath); err != nil { + t.Skipf("create symlink loop: %v", err) + } + if _, err := FindManifestPath(root); err == nil || errors.Is(err, ErrManifestNotFound) { + t.Fatalf("FindManifestPath stat error = %v, want propagated filesystem error", err) + } +} + +func TestResolveSourceFileProjectPropagatesMalformedManifest(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src", "main"+peeper.SourceExt) + if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(src, []byte("fn main() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, FileName), []byte("not valid toml"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := ResolveSourceFileProject(src); err == nil || !strings.Contains(err.Error(), "parse manifest") { + t.Fatalf("ResolveSourceFileProject error = %v, want parse manifest error", err) + } +} + +func TestLoadValidatesCompilerConstraint(t *testing.T) { + tests := []struct { + name string + constraint string + wantError string + }{ + {name: "missing"}, + {name: "compatible", constraint: "<=0.1.0"}, + {name: "malformed", constraint: "^0.1", wantError: "invalid compiler constraint"}, + {name: "incompatible", constraint: ">=0.2.0", wantError: "requires compiler"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + compilerLine := "" + if test.constraint != "" { + compilerLine = fmt.Sprintf("compiler = %q\n", test.constraint) + } + path := filepath.Join(root, FileName) + if err := os.WriteFile(path, []byte("name = \"app\"\n"+compilerLine+"build = \"program\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + file, err := Load(path) + if test.wantError == "" { + if err != nil { + t.Fatalf("Load compatible manifest: %v", err) + } + if file.Package.CompilerVersion != test.constraint { + t.Fatalf("compiler constraint = %q, want %q", file.Package.CompilerVersion, test.constraint) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("Load error = %v, want text %q", err, test.wantError) + } + }) + } +} + +func TestValidatePackageName(t *testing.T) { + for _, name := range []string{"app", "hello_peeper", "A1"} { + if err := ValidatePackageName(name); err != nil { + t.Fatalf("ValidatePackageName(%q): %v", name, err) + } + } + for _, name := range []string{"", "_", "1app", "hello-peeper", "hello peeper", "বাংলা"} { + if err := ValidatePackageName(name); err == nil { + t.Fatalf("ValidatePackageName(%q) succeeded", name) + } + } +} + func TestLoadSupportsDependencyTableSyntax(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, FileName) @@ -134,7 +221,7 @@ func TestWriteFileAtomicReplacesFileAndCleansFailedTemp(t *testing.T) { if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { t.Fatal(err) } - if err := writeFileAtomic(path, []byte("new"), 0o644); err != nil { + if err := WriteFileAtomic(path, []byte("new"), 0o644); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) @@ -156,7 +243,7 @@ func TestWriteFileAtomicReplacesFileAndCleansFailedTemp(t *testing.T) { if err := os.Mkdir(blocked, 0o755); err != nil { t.Fatal(err) } - if err := writeFileAtomic(blocked, []byte("data"), 0o644); err == nil { + if err := WriteFileAtomic(blocked, []byte("data"), 0o644); err == nil { t.Fatal("replacement of directory succeeded") } temps, err := filepath.Glob(filepath.Join(dir, ".blocked.tmp-*")) diff --git a/pkg/manifest/write.go b/pkg/manifest/write.go index 77b748b..33f4b90 100644 --- a/pkg/manifest/write.go +++ b/pkg/manifest/write.go @@ -7,7 +7,8 @@ import ( "path/filepath" ) -func writeFileAtomic(path string, data []byte, mode os.FileMode) error { +// WriteFileAtomic stages, syncs, and atomically replaces one file. +func WriteFileAtomic(path string, data []byte, mode os.FileMode) error { dir := filepath.Dir(path) if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() diff --git a/pkg/peeper/constants.go b/pkg/peeper/constants.go index b07c454..37432a2 100644 --- a/pkg/peeper/constants.go +++ b/pkg/peeper/constants.go @@ -1,5 +1,8 @@ package peeper +// CompilerVersion is canonical current Peeper compiler version. +const CompilerVersion = "0.1.0" + // SourceExt is canonical source file extension shared across compiler layers. const SourceExt = ".peep" diff --git a/pkg/registry/cache.go b/pkg/registry/cache.go index cef3171..e771683 100644 --- a/pkg/registry/cache.go +++ b/pkg/registry/cache.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" - "compiler/pkg/manifest" "compiler/pkg/remotes" ) @@ -23,28 +22,6 @@ func GetModulePath(cachePath, repoName, version string) (string, error) { return filepath.Join(cachePath, filepath.FromSlash(moduleID)), nil } -func IsModuleCached(cachePath, repoName, version string) bool { - modulePath, err := GetModulePath(cachePath, repoName, version) - if err != nil { - return false - } - return isModuleCached(modulePath) -} - -func isModuleCached(modulePath string) bool { - info, err := os.Lstat(modulePath) - if err != nil || !info.IsDir() { - return false - } - manifestPath := filepath.Join(modulePath, manifest.FileName) - info, err = os.Lstat(manifestPath) - if err != nil || !info.Mode().IsRegular() { - return false - } - _, err = manifest.Load(manifestPath) - return err == nil -} - func DeleteModule(cachePath, repoName, version string) error { modulePath, err := GetModulePath(cachePath, repoName, version) if err != nil { diff --git a/pkg/registry/cache_test.go b/pkg/registry/cache_test.go index b58890f..0223777 100644 --- a/pkg/registry/cache_test.go +++ b/pkg/registry/cache_test.go @@ -4,11 +4,9 @@ import ( "os" "path/filepath" "testing" - - "compiler/pkg/manifest" ) -func TestModuleCacheHelpers(t *testing.T) { +func TestModuleCachePathAndDelete(t *testing.T) { cache := t.TempDir() repo := "github.com/acme/math" ver := "1.2.3" @@ -17,22 +15,12 @@ func TestModuleCacheHelpers(t *testing.T) { t.Fatal(err) } - if IsModuleCached(cache, repo, ver) { - t.Fatalf("module should not be cached yet") - } if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } - if IsModuleCached(cache, repo, ver) { - t.Fatalf("empty module directory must not be cached") - } - manifestText := "name = \"math\"\nbuild = \"lib\"\n" - if err := os.WriteFile(filepath.Join(path, manifest.FileName), []byte(manifestText), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(path, "content"), []byte("data"), 0o644); err != nil { t.Fatal(err) } - if !IsModuleCached(cache, repo, ver) { - t.Fatalf("module should be cached") - } if err := DeleteModule(cache, repo, ver); err != nil { t.Fatalf("DeleteModule failed: %v", err) } @@ -54,57 +42,12 @@ func TestModuleCacheRejectsInvalidIdentity(t *testing.T) { if _, err := GetModulePath(t.TempDir(), test.repo, test.version); err == nil { t.Fatalf("GetModulePath(%q, %q) accepted invalid identity", test.repo, test.version) } - if IsModuleCached(t.TempDir(), test.repo, test.version) { - t.Fatalf("invalid identity reported as cached") - } if err := DeleteModule(t.TempDir(), test.repo, test.version); err == nil { t.Fatalf("DeleteModule(%q, %q) accepted invalid identity", test.repo, test.version) } } } -func TestModuleCacheRejectsSymlinks(t *testing.T) { - cache := t.TempDir() - repo := "github.com/acme/math" - version := "1.2.3" - modulePath, err := GetModulePath(cache, repo, version) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Dir(modulePath), 0o755); err != nil { - t.Fatal(err) - } - - externalModule := t.TempDir() - manifestText := "name = \"math\"\nbuild = \"lib\"\n" - if err := os.WriteFile(filepath.Join(externalModule, manifest.FileName), []byte(manifestText), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink(externalModule, modulePath); err != nil { - t.Skipf("module symlink unsupported: %v", err) - } - if IsModuleCached(cache, repo, version) { - t.Fatal("symlinked module directory reported as cached") - } - if err := os.Remove(modulePath); err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(modulePath, 0o755); err != nil { - t.Fatal(err) - } - externalManifest := filepath.Join(t.TempDir(), manifest.FileName) - if err := os.WriteFile(externalManifest, []byte(manifestText), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink(externalManifest, filepath.Join(modulePath, manifest.FileName)); err != nil { - t.Skipf("manifest symlink unsupported: %v", err) - } - if IsModuleCached(cache, repo, version) { - t.Fatal("symlinked manifest reported as cached") - } -} - func TestModulePathPreservesSafeLegacyVersion(t *testing.T) { cache := t.TempDir() path, err := GetModulePath(cache, " github.com/acme/pkg ", " v1 ") diff --git a/pkg/registry/checksum.go b/pkg/registry/checksum.go new file mode 100644 index 0000000..cdb9ff9 --- /dev/null +++ b/pkg/registry/checksum.go @@ -0,0 +1,86 @@ +package registry + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +func ModuleChecksum(modulePath string) (string, error) { + rootInfo, err := os.Lstat(modulePath) + if err != nil { + return "", fmt.Errorf("inspect module root: %w", err) + } + if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("module root is not a directory") + } + + paths := make([]string, 0) + err = filepath.WalkDir(modulePath, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == modulePath || entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("module contains unsupported file %q", path) + } + relative, err := filepath.Rel(modulePath, path) + if err != nil { + return err + } + paths = append(paths, filepath.ToSlash(relative)) + return nil + }) + if err != nil { + return "", fmt.Errorf("walk module: %w", err) + } + sort.Strings(paths) + + hasher := sha256.New() + var frame [8]byte + for _, relative := range paths { + binary.BigEndian.PutUint64(frame[:], uint64(len(relative))) + _, _ = hasher.Write(frame[:]) + _, _ = io.WriteString(hasher, relative) + + path := filepath.Join(modulePath, filepath.FromSlash(relative)) + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open module file %q: %w", relative, err) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return "", fmt.Errorf("inspect module file %q: %w", relative, err) + } + if !info.Mode().IsRegular() { + _ = file.Close() + return "", fmt.Errorf("module contains unsupported file %q", relative) + } + binary.BigEndian.PutUint64(frame[:], uint64(info.Size())) + _, _ = hasher.Write(frame[:]) + written, copyErr := io.Copy(hasher, io.LimitReader(file, info.Size()+1)) + closeErr := file.Close() + if copyErr != nil { + return "", fmt.Errorf("hash module file %q: %w", relative, copyErr) + } + if closeErr != nil { + return "", fmt.Errorf("close module file %q: %w", relative, closeErr) + } + if written != info.Size() { + return "", fmt.Errorf("module file %q changed while hashing", relative) + } + } + return "sha256:" + hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/pkg/registry/checksum_test.go b/pkg/registry/checksum_test.go new file mode 100644 index 0000000..0b83cf5 --- /dev/null +++ b/pkg/registry/checksum_test.go @@ -0,0 +1,94 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestModuleChecksumUsesPathsAndContentsOnly(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + for _, root := range []string{first, second} { + if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(first, "src", "b.peep"), []byte("b"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(first, "a.peep"), []byte("a"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(second, "a.peep"), []byte("a"), 0o400); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(second, "src", "b.peep"), []byte("b"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(second, "a.peep"), time.Unix(1, 0), time.Unix(2, 0)); err != nil { + t.Fatal(err) + } + + firstChecksum, err := ModuleChecksum(first) + if err != nil { + t.Fatal(err) + } + secondChecksum, err := ModuleChecksum(second) + if err != nil { + t.Fatal(err) + } + if firstChecksum != secondChecksum { + t.Fatalf("metadata changed checksum: %q != %q", firstChecksum, secondChecksum) + } + if len(firstChecksum) != len("sha256:")+64 { + t.Fatalf("checksum = %q", firstChecksum) + } + + if err := os.WriteFile(filepath.Join(second, "src", "b.peep"), []byte("changed"), 0o755); err != nil { + t.Fatal(err) + } + changed, err := ModuleChecksum(second) + if err != nil { + t.Fatal(err) + } + if changed == firstChecksum { + t.Fatal("content change preserved checksum") + } +} + +func TestModuleChecksumRejectsSymlinks(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "source"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("source", filepath.Join(root, "link")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if _, err := ModuleChecksum(root); err == nil { + t.Fatal("ModuleChecksum accepted symlink") + } +} + +func TestModuleChecksumLengthFramesPathsAndContents(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + if err := os.WriteFile(filepath.Join(first, "a"), []byte("bc"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(second, "ab"), []byte("c"), 0o644); err != nil { + t.Fatal(err) + } + firstChecksum, err := ModuleChecksum(first) + if err != nil { + t.Fatal(err) + } + secondChecksum, err := ModuleChecksum(second) + if err != nil { + t.Fatal(err) + } + if firstChecksum == secondChecksum { + t.Fatal("path/content boundary collision") + } +} diff --git a/pkg/registry/download.go b/pkg/registry/download.go index ff48f91..54a1357 100644 --- a/pkg/registry/download.go +++ b/pkg/registry/download.go @@ -5,6 +5,7 @@ import ( "compress/gzip" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -31,21 +32,25 @@ const ( archiveRequestTimeout = 5 * time.Minute ) -func DownloadRemotePackage(httpClient *http.Client, cachePath, repoName, version string, devConfig *manifest.DevConfig) error { +func DownloadRemotePackage(httpClient *http.Client, cachePath, repoName, version, expectedChecksum string, devConfig *manifest.DevConfig) (string, error) { version = strings.TrimSpace(version) if _, err := semver.Parse(version); err != nil { - return fmt.Errorf("invalid package version %q: %w", version, err) + return "", fmt.Errorf("invalid package version %q: %w", version, err) } + if err := manifest.ValidateLockfileChecksum(expectedChecksum); err != nil { + return "", fmt.Errorf("invalid expected package checksum: %w", err) + } + expectedChecksum = strings.ToLower(expectedChecksum) modulePath, err := GetModulePath(cachePath, repoName, version) if err != nil { - return err + return "", err } if devConfig != nil && devConfig.MockRemote && devConfig.MockPath != "" { - return downloadFromMock(modulePath, repoName, version, devConfig.MockPath) + return downloadFromMock(modulePath, repoName, version, expectedChecksum, devConfig.MockPath) } ctx, cancel := context.WithTimeout(context.Background(), archiveRequestTimeout) defer cancel() - return downloadFromGit(ctx, httpClient, modulePath, repoName, version) + return downloadFromGit(ctx, httpClient, modulePath, repoName, version, expectedChecksum) } func ListAvailableVersions(httpClient *http.Client, repoName string, devConfig *manifest.DevConfig) ([]string, error) { @@ -71,12 +76,12 @@ func ListAvailableVersions(httpClient *http.Client, repoName string, devConfig * } } -func downloadFromGit(ctx context.Context, httpClient *http.Client, modulePath, repoName, version string) error { +func downloadFromGit(ctx context.Context, httpClient *http.Client, modulePath, repoName, version, expectedChecksum string) (string, error) { archiveURL, err := packageArchiveURL(repoName, version) if err != nil { - return err + return "", err } - return stageModule(modulePath, func(dest string) error { + return stageModule(modulePath, expectedChecksum, func(dest string) error { archivePath, err := downloadFile(ctx, httpClient, archiveURL) if err != nil { return err @@ -86,37 +91,69 @@ func downloadFromGit(ctx context.Context, httpClient *http.Client, modulePath, r }) } -func stageModule(dest string, populate func(string) error) error { - if isModuleCached(dest) { - return nil - } - if _, err := os.Lstat(dest); err == nil { - if err := os.RemoveAll(dest); err != nil { - return fmt.Errorf("remove incomplete module cache: %w", err) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("inspect module cache: %w", err) - } +func stageModule(dest, expectedChecksum string, populate func(string) error) (string, error) { parent := filepath.Dir(dest) if err := os.MkdirAll(parent, 0o755); err != nil { - return fmt.Errorf("create module cache parent: %w", err) + return "", fmt.Errorf("create module cache parent: %w", err) } temp, err := os.MkdirTemp(parent, "."+filepath.Base(dest)+".tmp-*") if err != nil { - return fmt.Errorf("create temporary module cache: %w", err) + return "", fmt.Errorf("create temporary module cache: %w", err) } defer os.RemoveAll(temp) if err := populate(temp); err != nil { - return err + return "", err } if _, err := manifest.Load(filepath.Join(temp, manifest.FileName)); err != nil { - return fmt.Errorf("invalid package manifest: %w", err) + return "", fmt.Errorf("invalid package manifest: %w", err) } - if err := os.Rename(temp, dest); err != nil { - if isModuleCached(dest) { - return nil + checksum, err := ModuleChecksum(temp) + if err != nil { + return "", err + } + if expectedChecksum != "" && checksum != expectedChecksum { + return "", fmt.Errorf("package checksum mismatch: expected %s, got %s", expectedChecksum, checksum) + } + if err := replaceModuleCache(temp, dest); err != nil { + return "", err + } + return checksum, nil +} + +func replaceModuleCache(staged, dest string) error { + if _, err := os.Lstat(dest); os.IsNotExist(err) { + if err := os.Rename(staged, dest); err != nil { + return fmt.Errorf("publish module cache: %w", err) } - return fmt.Errorf("publish module cache: %w", err) + return nil + } else if err != nil { + return fmt.Errorf("inspect module cache: %w", err) + } + + backupFile, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".backup-*") + if err != nil { + return fmt.Errorf("reserve module cache backup: %w", err) + } + backup := backupFile.Name() + if err := backupFile.Close(); err != nil { + _ = os.Remove(backup) + return fmt.Errorf("close module cache backup: %w", err) + } + if err := os.Remove(backup); err != nil { + return fmt.Errorf("prepare module cache backup: %w", err) + } + if err := os.Rename(dest, backup); err != nil { + return fmt.Errorf("backup module cache: %w", err) + } + if err := os.Rename(staged, dest); err != nil { + publishErr := fmt.Errorf("publish module cache: %w", err) + if rollbackErr := os.Rename(backup, dest); rollbackErr != nil { + return errors.Join(publishErr, fmt.Errorf("restore module cache: %w", rollbackErr)) + } + return publishErr + } + if err := os.RemoveAll(backup); err != nil { + return fmt.Errorf("remove module cache backup: %w", err) } return nil } @@ -139,10 +176,10 @@ func packageArchiveURL(repoName, version string) (string, error) { } } -func downloadFromMock(modulePath, repoName, version, mockBasePath string) error { +func downloadFromMock(modulePath, repoName, version, expectedChecksum, mockBasePath string) (string, error) { mockBasePath, err := filepath.Abs(mockBasePath) if err != nil { - return fmt.Errorf("resolve mock path: %w", err) + return "", fmt.Errorf("resolve mock path: %w", err) } repoPath := remotes.StripProviderPrefix(repoName) packageName := filepath.Base(repoPath) @@ -157,9 +194,9 @@ func downloadFromMock(modulePath, repoName, version, mockBasePath string) error source = filepath.Join(mockBasePath, repoPath) } if _, err := os.Stat(source); err != nil { - return fmt.Errorf("mock package not found for %s", repoName) + return "", fmt.Errorf("mock package not found for %s", repoName) } - return stageModule(modulePath, func(dest string) error { + return stageModule(modulePath, expectedChecksum, func(dest string) error { return copyDir(source, dest) }) } @@ -519,10 +556,13 @@ func archiveTarget(destPath, name string) (string, string, bool, error) { } func copyDir(src, dst string) error { - info, err := os.Stat(src) + info, err := os.Lstat(src) if err != nil { return err } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("mock package path %q is not a directory", src) + } if err := os.MkdirAll(dst, info.Mode()); err != nil { return err } @@ -533,12 +573,19 @@ func copyDir(src, dst string) error { for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) - if entry.IsDir() { + info, err := entry.Info() + if err != nil { + return err + } + if info.IsDir() { if err := copyDir(srcPath, dstPath); err != nil { return err } continue } + if !info.Mode().IsRegular() { + return fmt.Errorf("mock package contains unsupported file %q", srcPath) + } if err := copyFile(srcPath, dstPath); err != nil { return err } diff --git a/pkg/registry/download_test.go b/pkg/registry/download_test.go index 05cb1e9..e5003b6 100644 --- a/pkg/registry/download_test.go +++ b/pkg/registry/download_test.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "testing" + + "compiler/pkg/manifest" ) type archiveEntry struct { @@ -365,11 +367,60 @@ func TestListAvailableVersionsRejectsTagWithoutName(t *testing.T) { } func TestDownloadRemotePackageRejectsNonStableVersion(t *testing.T) { - if err := DownloadRemotePackage(http.DefaultClient, t.TempDir(), "github.com/acme/pkg", "tag?redirect", nil); err == nil { + if _, err := DownloadRemotePackage(http.DefaultClient, t.TempDir(), "github.com/acme/pkg", "tag?redirect", "", nil); err == nil { t.Fatal("non-stable package version accepted") } } +func TestDownloadRemotePackageVerifiesBeforeReplacingCache(t *testing.T) { + root := t.TempDir() + cache := filepath.Join(root, "cache") + mock := filepath.Join(root, "mock") + dest, err := GetModulePath(cache, "github.com/acme/pkg", "v1.0.0") + if err != nil { + t.Fatal(err) + } + writePackageTree(t, dest, "old") + oldChecksum, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + source := filepath.Join(mock, "github.com", "acme", "pkg-v1.0.0") + writePackageTree(t, source, "new") + newChecksum, err := ModuleChecksum(source) + if err != nil { + t.Fatal(err) + } + dev := &manifest.DevConfig{MockRemote: true, MockPath: mock} + + if _, err := DownloadRemotePackage(http.DefaultClient, cache, "github.com/acme/pkg", "v1.0.0", oldChecksum, dev); err == nil { + t.Fatal("moved package matched old checksum") + } + afterMismatch, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + if afterMismatch != oldChecksum { + t.Fatalf("mismatch replaced cache: %q", afterMismatch) + } + + uppercaseExpected := "sha256:" + strings.ToUpper(strings.TrimPrefix(newChecksum, "sha256:")) + actual, err := DownloadRemotePackage(http.DefaultClient, cache, "github.com/acme/pkg", "v1.0.0", uppercaseExpected, dev) + if err != nil { + t.Fatal(err) + } + if actual != newChecksum { + t.Fatalf("download checksum = %q, want %q", actual, newChecksum) + } + afterReplace, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + if afterReplace != newChecksum { + t.Fatalf("replacement checksum = %q, want %q", afterReplace, newChecksum) + } +} + func TestStageModulePublishesOnlyValidPackage(t *testing.T) { cache := t.TempDir() dest, err := GetModulePath(cache, "github.com/acme/pkg", "1.0.0") @@ -379,13 +430,21 @@ func TestStageModulePublishesOnlyValidPackage(t *testing.T) { if err := os.MkdirAll(dest, 0o755); err != nil { t.Fatal(err) } - if err := stageModule(dest, func(temp string) error { + checksum, err := stageModule(dest, "", func(temp string) error { return os.WriteFile(filepath.Join(temp, "peeper.toml"), []byte("name = \"pkg\"\nbuild = \"lib\"\n"), 0o644) - }); err != nil { + }) + if err != nil { + t.Fatal(err) + } + if checksum == "" { + t.Fatal("stageModule returned empty checksum") + } + published, err := ModuleChecksum(dest) + if err != nil { t.Fatal(err) } - if !isModuleCached(dest) { - t.Fatal("valid staged package not published") + if published != checksum { + t.Fatalf("published checksum = %q, want %q", published, checksum) } } @@ -414,7 +473,7 @@ func TestStageModuleCleansFailedPublication(t *testing.T) { if err != nil { t.Fatal(err) } - if err := stageModule(dest, test.populate); err == nil { + if _, err := stageModule(dest, "", test.populate); err == nil { t.Fatal("invalid package published") } if _, err := os.Stat(dest); !os.IsNotExist(err) { @@ -430,3 +489,56 @@ func TestStageModuleCleansFailedPublication(t *testing.T) { }) } } + +func TestStageModulePreservesExistingCacheOnPopulateFailure(t *testing.T) { + dest := filepath.Join(t.TempDir(), "cache", "module") + writePackageTree(t, dest, "old") + before, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + if _, err := stageModule(dest, "", func(string) error { return errors.New("download failed") }); err == nil { + t.Fatal("stageModule ignored populate failure") + } + after, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("populate failure changed cache: %q != %q", after, before) + } +} + +func TestReplaceModuleCacheRollsBackFailedPublish(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, "cache", "module") + writePackageTree(t, dest, "old") + before, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + + if err := replaceModuleCache(filepath.Join(root, "missing-stage"), dest); err == nil { + t.Fatal("replaceModuleCache accepted missing stage") + } + after, err := ModuleChecksum(dest) + if err != nil { + t.Fatal(err) + } + if after != before { + t.Fatalf("failed publish changed cache: %q != %q", after, before) + } +} + +func writePackageTree(t *testing.T, root, source string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "peeper.toml"), []byte("name = \"pkg\"\nbuild = \"lib\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "src", "pkg.peep"), []byte(source), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/x_test/negative_print_printf_symbol/src/main.peep b/x_test/negative_print_printf_symbol/src/main.peep index 8a34848..649a86c 100644 --- a/x_test/negative_print_printf_symbol/src/main.peep +++ b/x_test/negative_print_printf_symbol/src/main.peep @@ -1,4 +1,5 @@ -fn printf() {} +#[extern] +fn printf() -> i32; fn main() -> i32 { print(1); diff --git a/x_test/owned_pointer_carrier/peeper.toml b/x_test/owned_pointer_carrier/peeper.toml index 0ee848f..0e0ef95 100644 --- a/x_test/owned_pointer_carrier/peeper.toml +++ b/x_test/owned_pointer_carrier/peeper.toml @@ -1,5 +1,5 @@ -[project] name = "owned_pointer_carrier" +build = "program" [test] mode = "check" diff --git a/x_test/review_consteval_gaps/peeper.toml b/x_test/review_consteval_gaps/peeper.toml index 5a3011a..c34c7db 100644 --- a/x_test/review_consteval_gaps/peeper.toml +++ b/x_test/review_consteval_gaps/peeper.toml @@ -1,5 +1,5 @@ name = "review_consteval_gaps" -build = "bin" +build = "program" [test] mode = "check" diff --git a/x_test/runtime_module_callable_names/peeper.toml b/x_test/runtime_module_callable_names/peeper.toml new file mode 100644 index 0000000..51a350a --- /dev/null +++ b/x_test/runtime_module_callable_names/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_module_callable_names" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_module_callable_names/src/alpha.peep b/x_test/runtime_module_callable_names/src/alpha.peep new file mode 100644 index 0000000..7aff70d --- /dev/null +++ b/x_test/runtime_module_callable_names/src/alpha.peep @@ -0,0 +1,16 @@ +struct Counter { + value: i32 +} + +fn Value() -> i32 { + return 10; +} + +fn (self: Counter) Read() -> i32 { + return self.value; +} + +fn Run() -> i32 { + let counter: Counter = .{ value = 1 }; + return Value() + counter.Read(); +} diff --git a/x_test/runtime_module_callable_names/src/beta.peep b/x_test/runtime_module_callable_names/src/beta.peep new file mode 100644 index 0000000..836e277 --- /dev/null +++ b/x_test/runtime_module_callable_names/src/beta.peep @@ -0,0 +1,16 @@ +struct Counter { + value: i32 +} + +fn Value() -> i32 { + return 20; +} + +fn (self: Counter) Read() -> i32 { + return self.value; +} + +fn Run() -> i32 { + let counter: Counter = .{ value = 2 }; + return Value() + counter.Read(); +} diff --git a/x_test/runtime_module_callable_names/src/main.peep b/x_test/runtime_module_callable_names/src/main.peep new file mode 100644 index 0000000..095e8d0 --- /dev/null +++ b/x_test/runtime_module_callable_names/src/main.peep @@ -0,0 +1,6 @@ +import "runtime_module_callable_names/alpha"; +import "runtime_module_callable_names/beta"; + +fn main() -> i32 { + return alpha::Run() + beta::Run() - 33; +} From aedd4fde0423c96069c9841c68870128dec87798 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:16:41 +0600 Subject: [PATCH 02/12] Validate native program entrypoints Reject invalid build and run entrypoints with M0006 before backend artifacts or clang. Keep check mode library-friendly and centralize host-only native link policy for build and run.\n\nEntrypoint validator protects executable ABI and pipeline phase invariants. Native target validator centralizes one policy shared by both native commands. --- cmd/build.go | 13 ++-- cmd/build_test.go | 15 +++++ cmd/command.go | 14 ++++- internal/diagnostics/codes.go | 1 + internal/pipeline/pipeline.go | 31 ++++++++++ internal/pipeline/pipeline_test.go | 62 +++++++++++++++++++ internal/project/context.go | 2 + x_test/entrypoint_i32/peeper.toml | 6 ++ x_test/entrypoint_i32/src/main.peep | 3 + x_test/entrypoint_void/peeper.toml | 6 ++ x_test/entrypoint_void/src/main.peep | 1 + x_test/fixtures_test.go | 7 +++ x_test/negative_entrypoint_extern/peeper.toml | 8 +++ .../negative_entrypoint_extern/src/main.peep | 2 + .../negative_entrypoint_imported/peeper.toml | 8 +++ .../src/external.peep | 1 + .../src/main.peep | 1 + .../negative_entrypoint_missing/peeper.toml | 8 +++ .../negative_entrypoint_missing/src/main.peep | 1 + .../negative_entrypoint_parameter/peeper.toml | 8 +++ .../src/main.peep | 1 + x_test/negative_entrypoint_return/peeper.toml | 8 +++ .../negative_entrypoint_return/src/main.peep | 3 + 23 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 x_test/entrypoint_i32/peeper.toml create mode 100644 x_test/entrypoint_i32/src/main.peep create mode 100644 x_test/entrypoint_void/peeper.toml create mode 100644 x_test/entrypoint_void/src/main.peep create mode 100644 x_test/negative_entrypoint_extern/peeper.toml create mode 100644 x_test/negative_entrypoint_extern/src/main.peep create mode 100644 x_test/negative_entrypoint_imported/peeper.toml create mode 100644 x_test/negative_entrypoint_imported/src/external.peep create mode 100644 x_test/negative_entrypoint_imported/src/main.peep create mode 100644 x_test/negative_entrypoint_missing/peeper.toml create mode 100644 x_test/negative_entrypoint_missing/src/main.peep create mode 100644 x_test/negative_entrypoint_parameter/peeper.toml create mode 100644 x_test/negative_entrypoint_parameter/src/main.peep create mode 100644 x_test/negative_entrypoint_return/peeper.toml create mode 100644 x_test/negative_entrypoint_return/src/main.peep diff --git a/cmd/build.go b/cmd/build.go index 77e5d4f..c684981 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -21,12 +21,13 @@ func compileEntry(path string, debugBuild bool, targetOS, targetArch string) (co rootDir := sourceProject.RootDir projectName := sourceProject.ProjectName cfg := project.Config{ - RootDir: rootDir, - ProjectName: projectName, - Extension: peeper.SourceExt, - TargetOS: targetOS, - TargetArch: targetArch, - BuildDebug: debugBuild, + RootDir: rootDir, + ProjectName: projectName, + Extension: peeper.SourceExt, + TargetOS: targetOS, + TargetArch: targetArch, + BuildDebug: debugBuild, + RequireEntrypoint: true, } compilerContext = compiler.NewCompilerContext(cfg, diagnostics.NewDiagnosticBag()) if err != nil { diff --git a/cmd/build_test.go b/cmd/build_test.go index 3462f2d..7d56826 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -2,11 +2,26 @@ package main import ( "reflect" + "runtime" "testing" "compiler/internal/project" ) +func TestValidateNativeLinkTarget(t *testing.T) { + if err := validateNativeLinkTarget(runtime.GOOS, runtime.GOARCH); err != nil { + t.Fatalf("host target rejected: %v", err) + } + + targetOS := "linux" + if runtime.GOOS == targetOS { + targetOS = "windows" + } + if err := validateNativeLinkTarget(targetOS, runtime.GOARCH); err == nil { + t.Fatalf("non-host target %s/%s accepted", targetOS, runtime.GOARCH) + } +} + func TestClangArgsForBuildRelease(t *testing.T) { args := clangArgsForBuild(project.Config{TargetOS: "linux"}, "x86_64-unknown-linux-gnu", []string{"a.ll", "b.ll"}, "demo") want := []string{"-target", "x86_64-unknown-linux-gnu", "-x", "ir", "a.ll", "-x", "ir", "b.ll", "-o", "demo"} diff --git a/cmd/command.go b/cmd/command.go index bc4c83d..93286fe 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -140,6 +140,9 @@ func buildCommand(args []string) error { if buildInfo.SelectedByDiscovery { colors.CYAN.Fprintf(os.Stderr, "using entry: %s\n", buildInfo.EntryPath) } + if err := validateNativeLinkTarget(opts.targetOS, opts.targetArch); err != nil { + return err + } ctx, entry := compileEntry(resolvedPath, opts.debugBuild, opts.targetOS, opts.targetArch) if err := emitAndCheckDiagnostics(ctx); err != nil { @@ -206,8 +209,8 @@ func runCommand(args []string) error { if buildInfo.SelectedByDiscovery { colors.CYAN.Fprintf(os.Stderr, "using entry: %s\n", buildInfo.EntryPath) } - if !target.IsHostTarget(opts.targetOS, opts.targetArch) { - return fmt.Errorf("run target %s/%s does not match host %s/%s", opts.targetOS, opts.targetArch, runtime.GOOS, runtime.GOARCH) + if err := validateNativeLinkTarget(opts.targetOS, opts.targetArch); err != nil { + return err } ctx, entry := compileEntry(resolvedPath, opts.debugBuild, opts.targetOS, opts.targetArch) @@ -253,6 +256,13 @@ func runCommand(args []string) error { return nil } +func validateNativeLinkTarget(targetOS, targetArch string) error { + if target.IsHostTarget(targetOS, targetArch) { + return nil + } + return fmt.Errorf("native linking target %s/%s does not match host %s/%s", targetOS, targetArch, runtime.GOOS, runtime.GOARCH) +} + type buildTarget struct { EntryPath string SelectedByDiscovery bool diff --git a/internal/diagnostics/codes.go b/internal/diagnostics/codes.go index bed8955..5da2069 100644 --- a/internal/diagnostics/codes.go +++ b/internal/diagnostics/codes.go @@ -80,6 +80,7 @@ const ( ErrInvalidImportPath = "M0003" ErrSymbolNotExported = "M0004" ErrAmbiguousImport = "M0005" + ErrInvalidEntrypoint = "M0006" // Style/Info codes (S prefix) InfoTrailingComma = "S0001" diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 0848c19..460090b 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -24,6 +24,7 @@ import ( "compiler/internal/semantics/definiteinit" "compiler/internal/semantics/ownership" "compiler/internal/semantics/resolver" + "compiler/internal/semantics/symbols" "compiler/internal/semantics/typechecker" "compiler/internal/semantics/typeinfo" "compiler/internal/semantics/usage" @@ -147,6 +148,12 @@ func (p *Pipeline) Run(entry *project.Module) error { return err } p.ctx.CompletedProjectPhase = phase.Usage + if p.ctx.Config.RequireEntrypoint { + validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.Key)) + if diag.HasErrors() { + return nil + } + } p.advanceModulesThrough(orderedModules, prelude, preludeInjected, phase.Backend, diag) if diag != nil && diag.HasErrors() { return nil @@ -168,6 +175,30 @@ func (p *Pipeline) Run(entry *project.Module) error { return nil } +func validateProgramEntrypoint(entry *project.Module, diag *diagnostics.DiagnosticBag) { + const message = "program entrypoint must be a local body-backed `fn main()` or `fn main() -> i32`" + if entry == nil || entry.ModuleScope == nil { + diag.AddError(diagnostics.ErrInvalidEntrypoint, message, nil, "") + return + } + + sym, found := entry.ModuleScope.LookupLocal("main") + if !found || sym == nil || sym.Kind != symbols.SymbolFunc { + diag.AddError(diagnostics.ErrInvalidEntrypoint, message, nil, "") + return + } + decl, declOK := sym.ASTNode.(*ast.FnDecl) + fnType, typeOK := sym.Type.(*typeinfo.FuncType) + validReturn := typeOK && fnType.Return == nil + if typeOK && fnType.Return != nil { + integer, ok := fnType.Return.(*typeinfo.IntegerType) + validReturn = ok && integer.Signed && integer.Bits == 32 + } + if !declOK || decl == nil || decl.Receiver != nil || decl.Body == nil || len(decl.TypeParams) != 0 || !typeOK || len(fnType.Params) != 0 || !validReturn { + diag.AddError(diagnostics.ErrInvalidEntrypoint, message, sym.Location, "invalid program entrypoint") + } +} + func (p *Pipeline) advanceModulesThrough(orderedModules []*project.Module, prelude *project.Module, preludeInjected bool, lastPhase phase.Phase, diag *diagnostics.DiagnosticBag) bool { for { if !preludeInjected && prelude != nil && prelude.ModuleScope != nil && prelude.Phase >= phase.Collected { diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 92b4de4..875a2d7 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -141,6 +141,68 @@ fn invalid(point: Point) { t.Fatalf("expected use-after-move diagnostic from constant false branch, got:\n%s", diag.EmitAllToString()) } +func TestPipelineRequiresBuildEntrypoint(t *testing.T) { + tests := []struct { + name string + src string + }{ + {name: "missing", src: `fn helper() {}`}, + {name: "parameter", src: `fn main(value: i32) {}`}, + {name: "wrong return", src: `fn main() -> bool { return true; }`}, + {name: "aliased return", src: `type ExitCode = i32; +fn main() -> ExitCode { return 0; }`}, + {name: "extern", src: `#[extern] +fn main();`}, + {name: "generic", src: `fn main() {}`}, + {name: "method", src: `struct App {} +fn (self: App) main() {}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", + Extension: peeper.SourceExt, + RequireEntrypoint: true, + }, "", tt.src) + for _, item := range diag.Diagnostics() { + if item != nil && item.Code == diagnostics.ErrInvalidEntrypoint { + return + } + } + t.Fatalf("expected invalid entrypoint diagnostic, got:\n%s", diag.EmitAllToString()) + }) + } +} + +func TestPipelineAcceptsBuildEntrypointReturns(t *testing.T) { + tests := []struct { + name string + src string + }{ + {name: "void", src: `fn main() {}`}, + {name: "i32", src: `fn main() -> i32 { return 0; }`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", + Extension: peeper.SourceExt, + RequireEntrypoint: true, + }, "", tt.src) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } +} + +func TestPipelineCheckAllowsMissingEntrypoint(t *testing.T) { + diag := buildPipelineTestWithConfig(t, project.Config{RootDir: ".", Extension: peeper.SourceExt}, "", `fn helper() {}`) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } +} + func TestPipelineImportsCoreAllocatorRawMallocFree(t *testing.T) { root := t.TempDir() libraryBase := filepath.Join(root, "libs") diff --git a/internal/project/context.go b/internal/project/context.go index f94736e..801da7d 100644 --- a/internal/project/context.go +++ b/internal/project/context.go @@ -84,6 +84,8 @@ type Config struct { TargetArch string // Emit debug-friendly artifacts. BuildDebug bool + // Require an executable program entrypoint before backend lowering. + RequireEntrypoint bool // Compile test entry points. TestMode bool // Optional single test name. diff --git a/x_test/entrypoint_i32/peeper.toml b/x_test/entrypoint_i32/peeper.toml new file mode 100644 index 0000000..2eddf71 --- /dev/null +++ b/x_test/entrypoint_i32/peeper.toml @@ -0,0 +1,6 @@ +name = "entrypoint_i32" +build = "program" + +[test] +mode = "build" +outcome = "success" diff --git a/x_test/entrypoint_i32/src/main.peep b/x_test/entrypoint_i32/src/main.peep new file mode 100644 index 0000000..2785917 --- /dev/null +++ b/x_test/entrypoint_i32/src/main.peep @@ -0,0 +1,3 @@ +fn main() -> i32 { + return 0; +} diff --git a/x_test/entrypoint_void/peeper.toml b/x_test/entrypoint_void/peeper.toml new file mode 100644 index 0000000..fc64a1a --- /dev/null +++ b/x_test/entrypoint_void/peeper.toml @@ -0,0 +1,6 @@ +name = "entrypoint_void" +build = "program" + +[test] +mode = "build" +outcome = "success" diff --git a/x_test/entrypoint_void/src/main.peep b/x_test/entrypoint_void/src/main.peep new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/x_test/entrypoint_void/src/main.peep @@ -0,0 +1 @@ +fn main() {} diff --git a/x_test/fixtures_test.go b/x_test/fixtures_test.go index 12bb739..5cdb21c 100644 --- a/x_test/fixtures_test.go +++ b/x_test/fixtures_test.go @@ -25,6 +25,7 @@ type fixtureExpectation struct { ProgramArgs []string StdoutContains []string StderrContains []string + StderrExcludes []string } func TestFixtureContracts(t *testing.T) { @@ -76,6 +77,7 @@ func readFixtureExpectation(t *testing.T, manifestPath string) fixtureExpectatio expectation.ProgramArgs = optionalFixtureValue[[]string](t, section, manifestPath, "program_args") expectation.StdoutContains = optionalFixtureValue[[]string](t, section, manifestPath, "stdout_contains") expectation.StderrContains = optionalFixtureValue[[]string](t, section, manifestPath, "stderr_contains") + expectation.StderrExcludes = optionalFixtureValue[[]string](t, section, manifestPath, "stderr_excludes") if !slices.Contains([]string{"check", "build", "run"}, expectation.Mode) { t.Fatalf("%s has invalid mode %q", manifestPath, expectation.Mode) } @@ -181,4 +183,9 @@ func checkFixtureOutcome(t *testing.T, expectation fixtureExpectation, stdout, s t.Fatalf("stderr missing %q:\n%s", text, stderr) } } + for _, text := range expectation.StderrExcludes { + if strings.Contains(stderr, text) { + t.Fatalf("stderr unexpectedly contains %q:\n%s", text, stderr) + } + } } diff --git a/x_test/negative_entrypoint_extern/peeper.toml b/x_test/negative_entrypoint_extern/peeper.toml new file mode 100644 index 0000000..b47cc55 --- /dev/null +++ b/x_test/negative_entrypoint_extern/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_entrypoint_extern" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["M0006"] +stderr_excludes = ["clang"] diff --git a/x_test/negative_entrypoint_extern/src/main.peep b/x_test/negative_entrypoint_extern/src/main.peep new file mode 100644 index 0000000..05f44f0 --- /dev/null +++ b/x_test/negative_entrypoint_extern/src/main.peep @@ -0,0 +1,2 @@ +#[extern] +fn main(); diff --git a/x_test/negative_entrypoint_imported/peeper.toml b/x_test/negative_entrypoint_imported/peeper.toml new file mode 100644 index 0000000..bba812a --- /dev/null +++ b/x_test/negative_entrypoint_imported/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_entrypoint_imported" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["M0006"] +stderr_excludes = ["clang"] diff --git a/x_test/negative_entrypoint_imported/src/external.peep b/x_test/negative_entrypoint_imported/src/external.peep new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/x_test/negative_entrypoint_imported/src/external.peep @@ -0,0 +1 @@ +fn main() {} diff --git a/x_test/negative_entrypoint_imported/src/main.peep b/x_test/negative_entrypoint_imported/src/main.peep new file mode 100644 index 0000000..88f1ee0 --- /dev/null +++ b/x_test/negative_entrypoint_imported/src/main.peep @@ -0,0 +1 @@ +import "negative_entrypoint_imported/external"; diff --git a/x_test/negative_entrypoint_missing/peeper.toml b/x_test/negative_entrypoint_missing/peeper.toml new file mode 100644 index 0000000..4fff0eb --- /dev/null +++ b/x_test/negative_entrypoint_missing/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_entrypoint_missing" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["M0006"] +stderr_excludes = ["clang"] diff --git a/x_test/negative_entrypoint_missing/src/main.peep b/x_test/negative_entrypoint_missing/src/main.peep new file mode 100644 index 0000000..1519936 --- /dev/null +++ b/x_test/negative_entrypoint_missing/src/main.peep @@ -0,0 +1 @@ +fn helper() {} diff --git a/x_test/negative_entrypoint_parameter/peeper.toml b/x_test/negative_entrypoint_parameter/peeper.toml new file mode 100644 index 0000000..953c06e --- /dev/null +++ b/x_test/negative_entrypoint_parameter/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_entrypoint_parameter" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["M0006"] +stderr_excludes = ["clang"] diff --git a/x_test/negative_entrypoint_parameter/src/main.peep b/x_test/negative_entrypoint_parameter/src/main.peep new file mode 100644 index 0000000..d07c76f --- /dev/null +++ b/x_test/negative_entrypoint_parameter/src/main.peep @@ -0,0 +1 @@ +fn main(value: i32) {} diff --git a/x_test/negative_entrypoint_return/peeper.toml b/x_test/negative_entrypoint_return/peeper.toml new file mode 100644 index 0000000..47fdd6f --- /dev/null +++ b/x_test/negative_entrypoint_return/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_entrypoint_return" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["M0006"] +stderr_excludes = ["clang"] diff --git a/x_test/negative_entrypoint_return/src/main.peep b/x_test/negative_entrypoint_return/src/main.peep new file mode 100644 index 0000000..3a268ab --- /dev/null +++ b/x_test/negative_entrypoint_return/src/main.peep @@ -0,0 +1,3 @@ +fn main() -> bool { + return true; +} From 796dd13eefc98d159489d43cc679df9332ae30e3 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:20:09 +0600 Subject: [PATCH 03/12] Harden JSON-RPC framing Reject bodies above 16 MiB before allocation or body reads while accepting the exact limit. Represent successful nil responses explicitly as result null and keep error envelopes result-free. --- internal/lsp/jsonrpc.go | 8 ++- internal/lsp/jsonrpc_test.go | 116 +++++++++++++++++++++++++++++++++++ internal/lsp/server.go | 4 +- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 internal/lsp/jsonrpc_test.go diff --git a/internal/lsp/jsonrpc.go b/internal/lsp/jsonrpc.go index fb36cd2..36a5ed5 100644 --- a/internal/lsp/jsonrpc.go +++ b/internal/lsp/jsonrpc.go @@ -9,6 +9,9 @@ import ( "strings" ) +// Maximum JSON-RPC message body accepted by the language server: 16 MiB. +const maxJSONRPCBodySize = 16 << 20 + type Request struct { JSONRPC string `json:"jsonrpc"` ID *json.RawMessage `json:"id,omitempty"` @@ -19,7 +22,7 @@ type Request struct { type Response struct { JSONRPC string `json:"jsonrpc"` ID *json.RawMessage `json:"id"` - Result any `json:"result,omitempty"` + Result *any `json:"result,omitempty"` Error *ResponseError `json:"error,omitempty"` } @@ -53,6 +56,9 @@ func readMessage(r *bufio.Reader) ([]byte, error) { if err != nil { return nil, fmt.Errorf("invalid Content-Length: %w", err) } + if cl > maxJSONRPCBodySize { + return nil, fmt.Errorf("Content-Length %d exceeds %d-byte limit", cl, maxJSONRPCBodySize) + } contentLength = cl } } diff --git a/internal/lsp/jsonrpc_test.go b/internal/lsp/jsonrpc_test.go new file mode 100644 index 0000000..1f1fc8c --- /dev/null +++ b/internal/lsp/jsonrpc_test.go @@ -0,0 +1,116 @@ +package lsp + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "testing" +) + +type rejectedBodyReader struct { + reads int +} + +func (r *rejectedBodyReader) Read([]byte) (int, error) { + r.reads++ + return 0, errors.New("body must not be read") +} + +func TestReadMessageAcceptsMaximumBodySize(t *testing.T) { + body := bytes.Repeat([]byte{'x'}, maxJSONRPCBodySize) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)) + message, err := readMessage(bufio.NewReader(io.MultiReader(strings.NewReader(header), bytes.NewReader(body)))) + if err != nil { + t.Fatalf("read exact-limit body: %v", err) + } + if !bytes.Equal(message, body) { + t.Fatalf("message length = %d, want %d", len(message), len(body)) + } +} + +func TestReadMessageRejectsOversizedBodyBeforeRead(t *testing.T) { + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxJSONRPCBodySize+1) + body := &rejectedBodyReader{} + _, err := readMessage(bufio.NewReaderSize(io.MultiReader(strings.NewReader(header), body), len(header))) + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized read error = %v", err) + } + if body.reads != 0 { + t.Fatalf("oversized body read %d times", body.reads) + } +} + +func TestReadMessageRejectsInvalidContentLength(t *testing.T) { + tests := []struct { + name string + input string + }{ + {name: "missing", input: "Content-Type: application/json\r\n\r\n"}, + {name: "empty", input: "Content-Length: \r\n\r\n"}, + {name: "nonnumeric", input: "Content-Length: nope\r\n\r\n"}, + {name: "zero", input: "Content-Length: 0\r\n\r\n"}, + {name: "negative", input: "Content-Length: -1\r\n\r\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := readMessage(bufio.NewReader(strings.NewReader(tt.input))); err == nil { + t.Fatal("expected Content-Length error") + } + }) + } +} + +func TestServerResponseResultAndErrorExclusivity(t *testing.T) { + tests := []struct { + name string + method string + wantResult string + wantError bool + }{ + {name: "shutdown null", method: "shutdown", wantResult: "null"}, + {name: "ordinary result", method: "initialize", wantResult: "object"}, + {name: "error", method: "unknown", wantError: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := json.RawMessage("1") + var input bytes.Buffer + if err := writeMessage(&input, Request{JSONRPC: "2.0", ID: &id, Method: tt.method}); err != nil { + t.Fatalf("write request: %v", err) + } + var output bytes.Buffer + if err := Run(&input, &output); err != nil { + t.Fatalf("Run: %v", err) + } + message, err := readMessage(bufio.NewReader(&output)) + if err != nil { + t.Fatalf("read response: %v", err) + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(message, &envelope); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + result, hasResult := envelope["result"] + _, hasError := envelope["error"] + if tt.wantError { + if hasResult || !hasError { + t.Fatalf("error envelope has result=%v error=%v: %s", hasResult, hasError, message) + } + return + } + if !hasResult || hasError { + t.Fatalf("success envelope has result=%v error=%v: %s", hasResult, hasError, message) + } + if tt.wantResult == "null" && string(result) != "null" { + t.Fatalf("result = %s, want null", result) + } + if tt.wantResult == "object" && (len(result) == 0 || result[0] != '{') { + t.Fatalf("result = %s, want object", result) + } + }) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 6f7568b..9dee76d 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -162,9 +162,11 @@ func Run(in io.Reader, out io.Writer) error { resp := Response{ JSONRPC: "2.0", ID: req.ID, - Result: result, Error: respErr, } + if respErr == nil { + resp.Result = &result + } outMu.Lock() _ = writeMessage(out, resp) outMu.Unlock() From c47af00877efa9bf44c5ccdadd2205a164ac2b38 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:29:42 +0600 Subject: [PATCH 04/12] Validate LSP file URIs and rename targets Parse and emit standards-compliant escaped file URIs across Unix, Windows drive, localhost, and UNC forms. Reject malformed request URIs as invalid params and ignore malformed notification URIs before mutation.\n\nCentralize lexer identifier grammar for rename validation, preserve typed JSON-RPC handler errors, and reject invalid or keyword rename targets before compilation. --- internal/frontend/lexer/lexer.go | 2 +- internal/frontend/token/identifier.go | 11 ++ internal/frontend/token/token_test.go | 13 ++ internal/lsp/completion.go | 5 +- internal/lsp/hover.go | 9 +- internal/lsp/jsonrpc.go | 20 +++ internal/lsp/jsonrpc_test.go | 6 +- internal/lsp/navigation.go | 22 +++- internal/lsp/server.go | 130 +++++++++++++------ internal/lsp/uri_test.go | 173 ++++++++++++++++++++++++++ 10 files changed, 341 insertions(+), 50 deletions(-) create mode 100644 internal/frontend/token/identifier.go create mode 100644 internal/lsp/uri_test.go diff --git a/internal/frontend/lexer/lexer.go b/internal/frontend/lexer/lexer.go index 186d8f1..c391cad 100644 --- a/internal/frontend/lexer/lexer.go +++ b/internal/frontend/lexer/lexer.go @@ -31,7 +31,7 @@ var regexPatterns = [...]regexPattern{ {regexp.MustCompile(`b'(?:\\.|[^'\\])*'`), byteCharHandler}, {regexp.MustCompile(`'(?:\\.|[^'\\])*'`), charHandler}, {regexp.MustCompile(numeric.NumberTokenPattern), numberHandler}, - {regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`), identifierHandler}, + {regexp.MustCompile(token.IdentifierPattern), identifierHandler}, {regexp.MustCompile(`::`), defaultHandler(token.DCOLON)}, {regexp.MustCompile(`==`), defaultHandler(token.EQ)}, {regexp.MustCompile(`!=`), defaultHandler(token.NEQ)}, diff --git a/internal/frontend/token/identifier.go b/internal/frontend/token/identifier.go new file mode 100644 index 0000000..227a5de --- /dev/null +++ b/internal/frontend/token/identifier.go @@ -0,0 +1,11 @@ +package token + +import "regexp" + +const IdentifierPattern = `[A-Za-z_][A-Za-z0-9_]*` + +var symbolNamePattern = regexp.MustCompile(`^(?:` + IdentifierPattern + `)$`) + +func IsValidSymbolName(name string) bool { + return name != "_" && symbolNamePattern.MatchString(name) && !IsKeyword(name) +} diff --git a/internal/frontend/token/token_test.go b/internal/frontend/token/token_test.go index dd663d2..fec99b8 100644 --- a/internal/frontend/token/token_test.go +++ b/internal/frontend/token/token_test.go @@ -40,6 +40,19 @@ func TestLookupIdentAndKeywordHelpers(t *testing.T) { } } +func TestValidSymbolName(t *testing.T) { + for _, name := range []string{"name", "Name2", "snake_case"} { + if !IsValidSymbolName(name) { + t.Fatalf("valid symbol name %q rejected", name) + } + } + for _, name := range []string{"", "_", "2name", "two words", "éclair", "fn", "let"} { + if IsValidSymbolName(name) { + t.Fatalf("invalid symbol name %q accepted", name) + } + } +} + func TestBuiltinTypeAndStringer(t *testing.T) { if !IsBuiltinType("i32") || IsBuiltinType("Point") { t.Fatalf("IsBuiltinType results unexpected") diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 9bd80b7..4b847f2 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -80,7 +80,10 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte if s == nil { return []CompletionItem{}, nil } - filePath := uriToPath(string(params.TextDocument.URI)) + filePath, err := uriToPath(string(params.TextDocument.URI)) + if err != nil { + return nil, invalidParams(err.Error()) + } sourceText, err := s.completionSource(filePath) if err != nil { return []CompletionItem{}, nil diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go index e348307..ed8fccf 100644 --- a/internal/lsp/hover.go +++ b/internal/lsp/hover.go @@ -705,8 +705,11 @@ func hoverDocComment(subject *hoverSubject) string { } func (s *ServerState) HandleHover(params HoverParams) (*Hover, error) { - path := uriToPath(string(params.TextDocument.URI)) - text, err := s.completionSource(path) + filePath, err := uriToPath(string(params.TextDocument.URI)) + if err != nil { + return nil, invalidParams(err.Error()) + } + text, err := s.completionSource(filePath) if err != nil { return nil, nil } @@ -714,7 +717,7 @@ func (s *ServerState) HandleHover(params HoverParams) (*Hover, error) { if !ok { return nil, nil } - subject := s.resolveHoverSubject(path, position) + subject := s.resolveHoverSubject(filePath, position) if subject == nil { return nil, nil } diff --git a/internal/lsp/jsonrpc.go b/internal/lsp/jsonrpc.go index 36a5ed5..9b4cfe4 100644 --- a/internal/lsp/jsonrpc.go +++ b/internal/lsp/jsonrpc.go @@ -3,6 +3,7 @@ package lsp import ( "bufio" "encoding/json" + "errors" "fmt" "io" "strconv" @@ -32,6 +33,25 @@ type ResponseError struct { Data any `json:"data,omitempty"` } +func (e *ResponseError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func invalidParams(message string) *ResponseError { + return &ResponseError{Code: -32602, Message: message} +} + +func responseErrorFrom(err error) *ResponseError { + var protocolErr *ResponseError + if errors.As(err, &protocolErr) { + return protocolErr + } + return &ResponseError{Code: -32603, Message: err.Error()} +} + type Notification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` diff --git a/internal/lsp/jsonrpc_test.go b/internal/lsp/jsonrpc_test.go index 1f1fc8c..d5f714a 100644 --- a/internal/lsp/jsonrpc_test.go +++ b/internal/lsp/jsonrpc_test.go @@ -78,8 +78,12 @@ func TestServerResponseResultAndErrorExclusivity(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { id := json.RawMessage("1") + params := json.RawMessage(nil) + if tt.method == "initialize" { + params = json.RawMessage(`{}`) + } var input bytes.Buffer - if err := writeMessage(&input, Request{JSONRPC: "2.0", ID: &id, Method: tt.method}); err != nil { + if err := writeMessage(&input, Request{JSONRPC: "2.0", ID: &id, Method: tt.method, Params: params}); err != nil { t.Fatalf("write request: %v", err) } var output bytes.Buffer diff --git a/internal/lsp/navigation.go b/internal/lsp/navigation.go index 7da7e70..6b88b44 100644 --- a/internal/lsp/navigation.go +++ b/internal/lsp/navigation.go @@ -4,6 +4,7 @@ import ( "slices" "compiler/internal/frontend/ast" + "compiler/internal/frontend/token" "compiler/internal/source" ) @@ -24,9 +25,12 @@ func symLocationsMatch(l1, l2 *source.Location) bool { } func (s *ServerState) HandleDefinition(params DefinitionParams) ([]Location, error) { - path := uriToPath(string(params.TextDocument.URI)) - ctx, mod := s.currentCompiledModule(path) - text, ok := sourceTextForFile(ctx, path) + filePath, err := uriToPath(string(params.TextDocument.URI)) + if err != nil { + return nil, invalidParams(err.Error()) + } + ctx, mod := s.currentCompiledModule(filePath) + text, ok := sourceTextForFile(ctx, filePath) position, positionOK := sourcePositionAt(text, params.Position) if !ok || !positionOK { return nil, nil @@ -58,9 +62,15 @@ func (s *ServerState) HandleDefinition(params DefinitionParams) ([]Location, err } func (s *ServerState) HandleRename(params RenameParams) (*WorkspaceEdit, error) { - path := uriToPath(string(params.TextDocument.URI)) - ctx, mod := s.currentCompiledModule(path) - text, ok := sourceTextForFile(ctx, path) + if !token.IsValidSymbolName(params.NewName) { + return nil, invalidParams("invalid rename target") + } + filePath, err := uriToPath(string(params.TextDocument.URI)) + if err != nil { + return nil, invalidParams(err.Error()) + } + ctx, mod := s.currentCompiledModule(filePath) + text, ok := sourceTextForFile(ctx, filePath) position, positionOK := sourcePositionAt(text, params.Position) if !ok || !positionOK { return nil, nil diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 9dee76d..558c680 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -4,8 +4,10 @@ import ( "bufio" "encoding/json" "errors" + "fmt" "io" - "path/filepath" + "net/url" + "path" "strings" "sync" "time" @@ -43,14 +45,22 @@ func Run(in io.Reader, out io.Writer) error { switch req.Method { case "initialize": var params InitializeParams - if err := json.Unmarshal(req.Params, ¶ms); err == nil { - if params.RootURI != nil { - state.RootDir = uriToPath(string(*params.RootURI)) - } else if params.RootPath != nil { - state.RootDir = *params.RootPath + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + respErr = invalidParams("Invalid params") + break + } + rootDir := state.RootDir + if params.RootURI != nil { + rootDir, err = uriToPath(string(*params.RootURI)) + if err != nil { + respErr = invalidParams(err.Error()) + break } - state.workspace = newWorkspaceIndex(state.RootDir) + } else if params.RootPath != nil { + rootDir = *params.RootPath } + state.RootDir = rootDir + state.workspace = newWorkspaceIndex(state.RootDir) result = InitializeResult{ Capabilities: ServerCapabilities{ TextDocumentSync: 1, // Full Sync @@ -74,20 +84,26 @@ func Run(in io.Reader, out io.Writer) error { case "textDocument/didOpen": var params DidOpenTextDocumentParams if err := json.Unmarshal(req.Params, ¶ms); err == nil { - path := uriToPath(string(params.TextDocument.URI)) - state.applyDocumentSnapshot(path, ¶ms.TextDocument.Text, ¶ms.TextDocument.Version) - publishComponentDiagnostics(out, &outMu, state, path, nil) + filePath, uriErr := uriToPath(string(params.TextDocument.URI)) + if uriErr != nil { + continue + } + state.applyDocumentSnapshot(filePath, ¶ms.TextDocument.Text, ¶ms.TextDocument.Version) + publishComponentDiagnostics(out, &outMu, state, filePath, nil) } continue case "textDocument/didChange": var params DidChangeTextDocumentParams if err := json.Unmarshal(req.Params, ¶ms); err == nil && len(params.ContentChanges) > 0 { - path := uriToPath(string(params.TextDocument.URI)) + filePath, uriErr := uriToPath(string(params.TextDocument.URI)) + if uriErr != nil { + continue + } // Under Full Sync, the first change has the entire file text - state.applyDocumentSnapshot(path, ¶ms.ContentChanges[0].Text, ¶ms.TextDocument.Version) - state.scheduleDiagnosticRefresh(path, diagnosticsDebounceDelay, func() { - publishComponentDiagnostics(out, &outMu, state, path, nil) + state.applyDocumentSnapshot(filePath, ¶ms.ContentChanges[0].Text, ¶ms.TextDocument.Version) + state.scheduleDiagnosticRefresh(filePath, diagnosticsDebounceDelay, func() { + publishComponentDiagnostics(out, &outMu, state, filePath, nil) }) } continue @@ -95,9 +111,12 @@ func Run(in io.Reader, out io.Writer) error { case "textDocument/didClose": var params TextDocumentIdentifier if err := json.Unmarshal(req.Params, ¶ms); err == nil { - path := uriToPath(string(params.URI)) - state.applyDocumentSnapshot(path, nil, nil) - publishComponentDiagnostics(out, &outMu, state, path, nil) + filePath, uriErr := uriToPath(string(params.URI)) + if uriErr != nil { + continue + } + state.applyDocumentSnapshot(filePath, nil, nil) + publishComponentDiagnostics(out, &outMu, state, filePath, nil) } continue @@ -106,10 +125,10 @@ func Run(in io.Reader, out io.Writer) error { if err := json.Unmarshal(req.Params, ¶ms); err == nil { result, err = state.HandleHover(params) if err != nil { - respErr = &ResponseError{Code: -32603, Message: err.Error()} + respErr = responseErrorFrom(err) } } else { - respErr = &ResponseError{Code: -32602, Message: "Invalid params"} + respErr = invalidParams("Invalid params") } case "textDocument/definition": @@ -117,10 +136,10 @@ func Run(in io.Reader, out io.Writer) error { if err := json.Unmarshal(req.Params, ¶ms); err == nil { result, err = state.HandleDefinition(params) if err != nil { - respErr = &ResponseError{Code: -32603, Message: err.Error()} + respErr = responseErrorFrom(err) } } else { - respErr = &ResponseError{Code: -32602, Message: "Invalid params"} + respErr = invalidParams("Invalid params") } case "textDocument/completion": @@ -128,10 +147,10 @@ func Run(in io.Reader, out io.Writer) error { if err := json.Unmarshal(req.Params, ¶ms); err == nil { result, err = state.HandleCompletion(params) if err != nil { - respErr = &ResponseError{Code: -32603, Message: err.Error()} + respErr = responseErrorFrom(err) } } else { - respErr = &ResponseError{Code: -32602, Message: "Invalid params"} + respErr = invalidParams("Invalid params") } case "textDocument/rename": @@ -139,10 +158,10 @@ func Run(in io.Reader, out io.Writer) error { if err := json.Unmarshal(req.Params, ¶ms); err == nil { result, err = state.HandleRename(params) if err != nil { - respErr = &ResponseError{Code: -32603, Message: err.Error()} + respErr = responseErrorFrom(err) } } else { - respErr = &ResponseError{Code: -32602, Message: "Invalid params"} + respErr = invalidParams("Invalid params") } case "shutdown": @@ -187,23 +206,58 @@ func publishComponentDiagnostics(w io.Writer, writeMu *sync.Mutex, state *Server publishDiagnosticSnapshot(w, writeMu, state, state.diagnosticSnapshot(entryFile, files)) } -func uriToPath(uri string) string { - if after, ok := strings.CutPrefix(uri, "file://"); ok { - path := after - if len(path) > 2 && path[0] == '/' && path[2] == ':' { - path = path[1:] - } - return filepath.Clean(filepath.ToSlash(path)) +func uriToPath(rawURI string) (string, error) { + parsed, err := url.Parse(rawURI) + if err != nil { + return "", fmt.Errorf("invalid file URI: %w", err) + } + if !strings.EqualFold(parsed.Scheme, "file") || parsed.Opaque != "" { + return "", fmt.Errorf("invalid file URI scheme %q", parsed.Scheme) + } + if parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(rawURI, "#") { + return "", fmt.Errorf("file URI must not contain query or fragment") + } + escapedPath := parsed.EscapedPath() + if escapedPath == "" { + return "", fmt.Errorf("file URI path is empty") + } + decodedPath, err := url.PathUnescape(escapedPath) + if err != nil { + return "", fmt.Errorf("invalid file URI path: %w", err) + } + if strings.ContainsRune(decodedPath, '\x00') { + return "", fmt.Errorf("file URI path contains NUL") + } + decodedPath = strings.ReplaceAll(decodedPath, `\`, "/") + if parsed.Host != "" && !strings.EqualFold(parsed.Host, "localhost") { + return "//" + parsed.Host + path.Clean("/"+strings.TrimPrefix(decodedPath, "/")), nil + } + clean := path.Clean(decodedPath) + if len(clean) >= 3 && clean[0] == '/' && isWindowsDrivePath(clean[1:]) { + clean = clean[1:] + } + return clean, nil +} + +func pathToURI(filePath string) string { + slashPath := strings.ReplaceAll(filePath, `\`, "/") + if strings.HasPrefix(slashPath, "//") { + authority, rest, _ := strings.Cut(strings.TrimPrefix(slashPath, "//"), "/") + return (&url.URL{Scheme: "file", Host: authority, Path: path.Clean("/" + rest)}).String() + } + clean := path.Clean(slashPath) + if isWindowsDrivePath(clean) || !strings.HasPrefix(clean, "/") { + clean = "/" + clean } - return uri + return (&url.URL{Scheme: "file", Path: clean}).String() } -func pathToURI(path string) string { - clean := filepath.ToSlash(filepath.Clean(path)) - if len(clean) > 0 && clean[0] != '/' { - return "file:///" + clean +func isWindowsDrivePath(filePath string) bool { + if len(filePath) < 3 || filePath[1] != ':' || filePath[2] != '/' { + return false } - return "file://" + clean + drive := filePath[0] + return drive >= 'A' && drive <= 'Z' || drive >= 'a' && drive <= 'z' } func publishDiagnosticSnapshot(w io.Writer, writeMu *sync.Mutex, state *ServerState, snapshot *diagnosticSnapshot) { diff --git a/internal/lsp/uri_test.go b/internal/lsp/uri_test.go new file mode 100644 index 0000000..5716e91 --- /dev/null +++ b/internal/lsp/uri_test.go @@ -0,0 +1,173 @@ +package lsp + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "testing" +) + +func TestFileURIToPath(t *testing.T) { + tests := []struct { + name string + uri string + want string + }{ + {name: "unix escapes", uri: "file:///tmp/A%20B/%C3%A9%23%25.peep", want: "/tmp/A B/é#%.peep"}, + {name: "decode once", uri: "file:///tmp/%2520.peep", want: "/tmp/%20.peep"}, + {name: "localhost", uri: "file://localhost/tmp/A%20B.peep", want: "/tmp/A B.peep"}, + {name: "windows drive", uri: "file:///C:/Work%20Dir/main.peep", want: "C:/Work Dir/main.peep"}, + {name: "unc authority", uri: "file://server/share/A%20B.peep", want: "//server/share/A B.peep"}, + {name: "clean path", uri: "file:///tmp/one/../two.peep", want: "/tmp/two.peep"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := uriToPath(tt.uri) + if err != nil { + t.Fatalf("uriToPath: %v", err) + } + if got != tt.want { + t.Fatalf("uriToPath(%q) = %q, want %q", tt.uri, got, tt.want) + } + }) + } +} + +func TestFileURIToPathRejectsInvalidInput(t *testing.T) { + for _, uri := range []string{ + "https://example.com/main.peep", + "file:///tmp/%zz.peep", + "file:///tmp/main.peep?version=1", + "file:///tmp/main.peep#section", + "file:///tmp/%00.peep", + "file://localhost", + } { + t.Run(uri, func(t *testing.T) { + if _, err := uriToPath(uri); err == nil { + t.Fatalf("uriToPath(%q) succeeded", uri) + } + }) + } +} + +func TestPathToFileURI(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {name: "unix", path: "/tmp/A B/é#%.peep", want: "file:///tmp/A%20B/%C3%A9%23%25.peep"}, + {name: "windows drive", path: `C:\Work Dir\é#%.peep`, want: "file:///C:/Work%20Dir/%C3%A9%23%25.peep"}, + {name: "unc", path: `\\server\share\A B#%.peep`, want: "file://server/share/A%20B%23%25.peep"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pathToURI(tt.path); got != tt.want { + t.Fatalf("pathToURI(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestRenameRejectsInvalidSymbolNamesBeforeCompilation(t *testing.T) { + state := NewServerState() + for _, name := range []string{"", "_", "123name", "two words", "éclair", "fn", "let"} { + t.Run(name, func(t *testing.T) { + _, err := state.HandleRename(RenameParams{ + TextDocument: TextDocumentIdentifier{URI: "file:///missing.peep"}, + NewName: name, + }) + var responseErr *ResponseError + if !errors.As(err, &responseErr) || responseErr.Code != -32602 { + t.Fatalf("rename error = %v, want invalid params", err) + } + if state.LastCtx != nil { + t.Fatal("invalid rename compiled source") + } + }) + } +} + +func TestMalformedRequestURIMapsToInvalidParams(t *testing.T) { + invalidRoot := DocumentURI("https://example.com") + tests := []struct { + method string + params any + }{ + {method: "initialize", params: InitializeParams{RootURI: &invalidRoot}}, + {method: "textDocument/hover", params: HoverParams{TextDocumentPositionParams: TextDocumentPositionParams{TextDocument: TextDocumentIdentifier{URI: "https://example.com/main.peep"}}}}, + {method: "textDocument/definition", params: DefinitionParams{TextDocumentPositionParams: TextDocumentPositionParams{TextDocument: TextDocumentIdentifier{URI: "https://example.com/main.peep"}}}}, + {method: "textDocument/completion", params: CompletionParams{TextDocumentPositionParams: TextDocumentPositionParams{TextDocument: TextDocumentIdentifier{URI: "https://example.com/main.peep"}}}}, + {method: "textDocument/rename", params: RenameParams{TextDocument: TextDocumentIdentifier{URI: "https://example.com/main.peep"}, NewName: "valid_name"}}, + } + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + params, err := json.Marshal(tt.params) + if err != nil { + t.Fatalf("marshal params: %v", err) + } + id := json.RawMessage("1") + var input bytes.Buffer + if err := writeMessage(&input, Request{JSONRPC: "2.0", ID: &id, Method: tt.method, Params: params}); err != nil { + t.Fatalf("write request: %v", err) + } + var output bytes.Buffer + if err := Run(&input, &output); err != nil { + t.Fatalf("Run: %v", err) + } + message, err := readMessage(bufio.NewReader(&output)) + if err != nil { + t.Fatalf("read response: %v", err) + } + var response Response + if err := json.Unmarshal(message, &response); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if response.Error == nil || response.Error.Code != -32602 || response.Result != nil { + t.Fatalf("response = %+v, want invalid params without result", response) + } + }) + } +} + +func TestResponseErrorMappingPreservesProtocolErrors(t *testing.T) { + protocolErr := invalidParams("bad input") + if got := responseErrorFrom(protocolErr); got != protocolErr { + t.Fatalf("typed protocol error replaced: got %+v", got) + } + if got := responseErrorFrom(errors.New("boom")); got.Code != -32603 || got.Message != "boom" { + t.Fatalf("internal error mapping = %+v", got) + } +} + +func TestMalformedNotificationURIDoesNotPublishOrMutateProtocolState(t *testing.T) { + params, err := json.Marshal(DidOpenTextDocumentParams{TextDocument: TextDocumentItem{ + URI: "https://example.com/main.peep", + Text: "fn main() {}", + }}) + if err != nil { + t.Fatalf("marshal params: %v", err) + } + id := json.RawMessage("1") + var input bytes.Buffer + for _, request := range []Request{ + {JSONRPC: "2.0", Method: "textDocument/didOpen", Params: params}, + {JSONRPC: "2.0", ID: &id, Method: "shutdown"}, + } { + if err := writeMessage(&input, request); err != nil { + t.Fatalf("write request: %v", err) + } + } + var output bytes.Buffer + if err := Run(&input, &output); err != nil { + t.Fatalf("Run: %v", err) + } + reader := bufio.NewReader(&output) + if _, err := readMessage(reader); err != nil { + t.Fatalf("read shutdown response: %v", err) + } + if _, err := readMessage(reader); err == nil { + t.Fatal("malformed notification URI produced extra output") + } +} From f6d3fadc0ffb9f438db72091fba608676bfe6727 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:44:05 +0600 Subject: [PATCH 05/12] Propagate LSP write failures Make protocolWriter the serialized output owner so response and diagnostic write failures are retained across synchronous and debounced publication paths. The writer boundary is required to protect the first-error and cancellation invariant across concurrent diagnostic publications. --- internal/lsp/jsonrpc.go | 37 +++++++++++ internal/lsp/jsonrpc_test.go | 53 ++++++++++++++++ internal/lsp/server.go | 74 +++++++++++++--------- internal/lsp/server_test.go | 119 +++++++++++++++++++++++++++++++---- internal/lsp/state.go | 20 ++++-- 5 files changed, 259 insertions(+), 44 deletions(-) diff --git a/internal/lsp/jsonrpc.go b/internal/lsp/jsonrpc.go index 9b4cfe4..2557791 100644 --- a/internal/lsp/jsonrpc.go +++ b/internal/lsp/jsonrpc.go @@ -8,6 +8,7 @@ import ( "io" "strconv" "strings" + "sync" ) // Maximum JSON-RPC message body accepted by the language server: 16 MiB. @@ -58,6 +59,42 @@ type Notification struct { Params any `json:"params,omitempty"` } +type protocolWriter struct { + out io.Writer + mu sync.Mutex + firstErr error + failureCh chan struct{} +} + +func newProtocolWriter(out io.Writer) *protocolWriter { + return &protocolWriter{out: out, failureCh: make(chan struct{})} +} + +func (w *protocolWriter) write(payload any) error { + w.mu.Lock() + defer w.mu.Unlock() + if w.firstErr != nil { + return w.firstErr + } + if err := writeMessage(w.out, payload); err != nil { + w.firstErr = err + close(w.failureCh) + return err + } + return nil +} + +func (w *protocolWriter) writeError() error { + select { + case <-w.failureCh: + w.mu.Lock() + defer w.mu.Unlock() + return w.firstErr + default: + return nil + } +} + func readMessage(r *bufio.Reader) ([]byte, error) { var contentLength int for { diff --git a/internal/lsp/jsonrpc_test.go b/internal/lsp/jsonrpc_test.go index d5f714a..d986f48 100644 --- a/internal/lsp/jsonrpc_test.go +++ b/internal/lsp/jsonrpc_test.go @@ -15,6 +15,20 @@ type rejectedBodyReader struct { reads int } +type failingProtocolOutput struct { + failAt int + writes int + err error +} + +func (w *failingProtocolOutput) Write(p []byte) (int, error) { + w.writes++ + if w.writes == w.failAt { + return 0, w.err + } + return len(p), nil +} + func (r *rejectedBodyReader) Read([]byte) (int, error) { r.reads++ return 0, errors.New("body must not be read") @@ -118,3 +132,42 @@ func TestServerResponseResultAndErrorExclusivity(t *testing.T) { }) } } + +func TestRunReturnsResponseWriteFailure(t *testing.T) { + tests := []struct { + name string + failAt int + }{ + {name: "header", failAt: 1}, + {name: "body", failAt: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := json.RawMessage("1") + var input bytes.Buffer + if err := writeMessage(&input, Request{JSONRPC: "2.0", ID: &id, Method: "shutdown"}); err != nil { + t.Fatalf("write request: %v", err) + } + want := errors.New(tt.name + " write failed") + output := &failingProtocolOutput{failAt: tt.failAt, err: want} + if err := Run(&input, output); !errors.Is(err, want) { + t.Fatalf("Run error = %v, want %v", err, want) + } + }) + } +} + +func TestProtocolWriterStopsAfterFirstFailure(t *testing.T) { + want := errors.New("header write failed") + output := &failingProtocolOutput{failAt: 1, err: want} + writer := newProtocolWriter(output) + if err := writer.write(Notification{JSONRPC: "2.0", Method: "first"}); !errors.Is(err, want) { + t.Fatalf("first write error = %v, want %v", err, want) + } + if err := writer.write(Notification{JSONRPC: "2.0", Method: "second"}); !errors.Is(err, want) { + t.Fatalf("second write error = %v, want %v", err, want) + } + if output.writes != 1 { + t.Fatalf("underlying writes = %d, want 1", output.writes) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 558c680..13404e6 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -9,7 +9,6 @@ import ( "net/url" "path" "strings" - "sync" "time" "compiler/internal/diagnostics" @@ -22,17 +21,28 @@ const diagnosticsDebounceDelay = 150 * time.Millisecond func Run(in io.Reader, out io.Writer) error { reader := bufio.NewReader(in) state := NewServerState() - var outMu sync.Mutex + writer := newProtocolWriter(out) for { + if err := writer.writeError(); err != nil { + return err + } bytes, err := readMessage(reader) if err != nil { + if writeErr := writer.writeError(); writeErr != nil { + return writeErr + } if errors.Is(err, io.EOF) { - state.waitForScheduledDiagnostics() - return nil + if err := state.waitForScheduledDiagnostics(); err != nil { + return err + } + return writer.writeError() } return err } + if err := writer.writeError(); err != nil { + return err + } var req Request if err := json.Unmarshal(bytes, &req); err != nil { @@ -78,7 +88,9 @@ func Run(in io.Reader, out io.Writer) error { } case "initialized": - publishWorkspaceDiagnostics(out, &outMu, state) + if err := publishWorkspaceDiagnostics(writer, state); err != nil { + return err + } continue case "textDocument/didOpen": @@ -89,7 +101,9 @@ func Run(in io.Reader, out io.Writer) error { continue } state.applyDocumentSnapshot(filePath, ¶ms.TextDocument.Text, ¶ms.TextDocument.Version) - publishComponentDiagnostics(out, &outMu, state, filePath, nil) + if err := publishComponentDiagnostics(writer, state, filePath, nil); err != nil { + return err + } } continue @@ -102,8 +116,8 @@ func Run(in io.Reader, out io.Writer) error { } // Under Full Sync, the first change has the entire file text state.applyDocumentSnapshot(filePath, ¶ms.ContentChanges[0].Text, ¶ms.TextDocument.Version) - state.scheduleDiagnosticRefresh(filePath, diagnosticsDebounceDelay, func() { - publishComponentDiagnostics(out, &outMu, state, filePath, nil) + state.scheduleDiagnosticRefresh(filePath, diagnosticsDebounceDelay, func() error { + return publishComponentDiagnostics(writer, state, filePath, nil) }) } continue @@ -116,7 +130,9 @@ func Run(in io.Reader, out io.Writer) error { continue } state.applyDocumentSnapshot(filePath, nil, nil) - publishComponentDiagnostics(out, &outMu, state, filePath, nil) + if err := publishComponentDiagnostics(writer, state, filePath, nil); err != nil { + return err + } } continue @@ -168,8 +184,10 @@ func Run(in io.Reader, out io.Writer) error { result = nil case "exit": - state.waitForScheduledDiagnostics() - return nil + if err := state.waitForScheduledDiagnostics(); err != nil { + return err + } + return writer.writeError() default: if req.ID != nil { @@ -186,24 +204,27 @@ func Run(in io.Reader, out io.Writer) error { if respErr == nil { resp.Result = &result } - outMu.Lock() - _ = writeMessage(out, resp) - outMu.Unlock() + if err := writer.write(resp); err != nil { + return err + } } } } -func publishWorkspaceDiagnostics(w io.Writer, writeMu *sync.Mutex, state *ServerState) { +func publishWorkspaceDiagnostics(writer *protocolWriter, state *ServerState) error { for _, snapshot := range state.workspaceDiagnosticSnapshots() { - publishDiagnosticSnapshot(w, writeMu, state, snapshot) + if err := publishDiagnosticSnapshot(writer, state, snapshot); err != nil { + return err + } } + return nil } -func publishComponentDiagnostics(w io.Writer, writeMu *sync.Mutex, state *ServerState, entryFile string, files []string) { +func publishComponentDiagnostics(writer *protocolWriter, state *ServerState, entryFile string, files []string) error { if state == nil { - return + return nil } - publishDiagnosticSnapshot(w, writeMu, state, state.diagnosticSnapshot(entryFile, files)) + return publishDiagnosticSnapshot(writer, state, state.diagnosticSnapshot(entryFile, files)) } func uriToPath(rawURI string) (string, error) { @@ -260,9 +281,9 @@ func isWindowsDrivePath(filePath string) bool { return drive >= 'A' && drive <= 'Z' || drive >= 'a' && drive <= 'z' } -func publishDiagnosticSnapshot(w io.Writer, writeMu *sync.Mutex, state *ServerState, snapshot *diagnosticSnapshot) { +func publishDiagnosticSnapshot(writer *protocolWriter, state *ServerState, snapshot *diagnosticSnapshot) error { if state == nil || snapshot == nil || snapshot.ctx == nil || snapshot.ctx.Diagnostics == nil { - return + return nil } notifications := diagnosticNotifications(snapshot) state.publishMu.Lock() @@ -271,17 +292,14 @@ func publishDiagnosticSnapshot(w io.Writer, writeMu *sync.Mutex, state *ServerSt stale := state.diagGeneration != snapshot.generation state.mu.Unlock() if stale { - return + return nil } for _, notification := range notifications { - if writeMu != nil { - writeMu.Lock() - } - _ = writeMessage(w, notification) - if writeMu != nil { - writeMu.Unlock() + if err := writer.write(notification); err != nil { + return err } } + return nil } func diagnosticNotifications(snapshot *diagnosticSnapshot) []Notification { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index ed61de5..75074de 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "io" "path/filepath" "strconv" @@ -117,7 +118,9 @@ func diagnosticForVersion(published []PublishDiagnosticsParams, version int) (Pu func publishCurrentDiagnostics(t *testing.T, state *ServerState, filePath string) PublishDiagnosticsParams { t.Helper() var output bytes.Buffer - publishDiagnosticSnapshot(&output, nil, state, state.diagnosticSnapshot(filePath, nil)) + if err := publishDiagnosticSnapshot(newProtocolWriter(&output), state, state.diagnosticSnapshot(filePath, nil)); err != nil { + t.Fatalf("publish diagnostics: %v", err) + } message, err := readMessage(bufio.NewReader(&output)) if err != nil { t.Fatalf("read published diagnostics: %v", err) @@ -646,11 +649,12 @@ func TestScheduleDiagnosticRefreshCoalescesRapidChanges(t *testing.T) { var mu sync.Mutex calls := 0 done := make(chan struct{}, 2) - publish := func() { + publish := func() error { mu.Lock() calls++ mu.Unlock() done <- struct{}{} + return nil } state.scheduleDiagnosticRefresh(filePath, 20*time.Millisecond, publish) @@ -671,6 +675,85 @@ func TestScheduleDiagnosticRefreshCoalescesRapidChanges(t *testing.T) { } } +func TestScheduledDiagnosticFailureIsReturned(t *testing.T) { + state := NewServerState() + want := errors.New("diagnostic write failed") + state.scheduleDiagnosticRefresh("/tmp/main.peep", 0, func() error { return want }) + if err := state.waitForScheduledDiagnostics(); !errors.Is(err, want) { + t.Fatalf("scheduled diagnostic error = %v, want %v", err, want) + } +} + +func TestRunReturnsSynchronousDiagnosticWriteFailure(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "main"+peeper.SourceExt) + openParams, err := json.Marshal(DidOpenTextDocumentParams{TextDocument: TextDocumentItem{ + URI: DocumentURI(pathToURI(filePath)), + Text: "fn main() {}\n", + }}) + if err != nil { + t.Fatalf("marshal open params: %v", err) + } + var input bytes.Buffer + if err := writeMessage(&input, Request{JSONRPC: "2.0", Method: "textDocument/didOpen", Params: openParams}); err != nil { + t.Fatalf("write open request: %v", err) + } + want := errors.New("diagnostic header failed") + output := &failingProtocolOutput{failAt: 1, err: want} + if err := Run(&input, output); !errors.Is(err, want) { + t.Fatalf("Run error = %v, want %v", err, want) + } +} + +func TestRunReturnsDebouncedDiagnosticWriteFailureOnProtocolEnd(t *testing.T) { + tests := []struct { + name string + exit bool + }{ + {name: "EOF"}, + {name: "exit", exit: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "main"+peeper.SourceExt) + openParams, err := json.Marshal(DidOpenTextDocumentParams{TextDocument: TextDocumentItem{ + URI: DocumentURI(pathToURI(filePath)), + Text: "fn main() {}\n", + }}) + if err != nil { + t.Fatalf("marshal open params: %v", err) + } + changeParams, err := json.Marshal(DidChangeTextDocumentParams{ + TextDocument: VersionedTextDocumentIdentifier{URI: DocumentURI(pathToURI(filePath)), Version: 2}, + ContentChanges: []TextDocumentContentChangeEvent{{Text: "fn main() -> i32 { return 0; }\n"}}, + }) + if err != nil { + t.Fatalf("marshal change params: %v", err) + } + var input bytes.Buffer + for _, request := range []Request{ + {JSONRPC: "2.0", Method: "textDocument/didOpen", Params: openParams}, + {JSONRPC: "2.0", Method: "textDocument/didChange", Params: changeParams}, + } { + if err := writeMessage(&input, request); err != nil { + t.Fatalf("write %s request: %v", request.Method, err) + } + } + if tt.exit { + if err := writeMessage(&input, Request{JSONRPC: "2.0", Method: "exit"}); err != nil { + t.Fatalf("write exit request: %v", err) + } + } + want := errors.New("debounced diagnostic header failed") + output := &failingProtocolOutput{failAt: 3, err: want} + if err := Run(&input, output); !errors.Is(err, want) { + t.Fatalf("Run error = %v, want %v", err, want) + } + }) + } +} + func TestHoverShowsExplicitTypeForImportedCallBinding(t *testing.T) { root := t.TempDir() writeWorkspaceProjectConfig(t, root, "app") @@ -1460,12 +1543,17 @@ func TestDiagnosticSnapshotDiscardsStaleGenerationAndPublishesVersion(t *testing state.applyDocumentSnapshot(filePath, &second, &secondVersion) var output bytes.Buffer - publishDiagnosticSnapshot(&output, nil, state, stale) + writer := newProtocolWriter(&output) + if err := publishDiagnosticSnapshot(writer, state, stale); err != nil { + t.Fatalf("publish stale diagnostics: %v", err) + } if output.Len() != 0 { t.Fatalf("stale snapshot published %d bytes", output.Len()) } - publishDiagnosticSnapshot(&output, nil, state, state.diagnosticSnapshot(filePath, nil)) + if err := publishDiagnosticSnapshot(writer, state, state.diagnosticSnapshot(filePath, nil)); err != nil { + t.Fatalf("publish current diagnostics: %v", err) + } message, err := readMessage(bufio.NewReader(&output)) if err != nil { t.Fatalf("read current diagnostics: %v", err) @@ -1497,10 +1585,9 @@ func TestDocumentMutationWaitsForCheckedDiagnosticPublication(t *testing.T) { entered: make(chan struct{}, 1), release: make(chan struct{}), } - published := make(chan struct{}) + published := make(chan error, 1) go func() { - publishDiagnosticSnapshot(writer, nil, state, snapshot) - close(published) + published <- publishDiagnosticSnapshot(newProtocolWriter(writer), state, snapshot) }() select { case <-writer.entered: @@ -1521,7 +1608,10 @@ func TestDocumentMutationWaitsForCheckedDiagnosticPublication(t *testing.T) { close(writer.release) select { - case <-published: + case err := <-published: + if err != nil { + t.Fatalf("publish diagnostics: %v", err) + } case <-time.After(time.Second): t.Fatal("diagnostic publication did not complete") } @@ -1696,18 +1786,25 @@ func TestConcurrentDifferentFileDiagnosticSnapshotsBothPublish(t *testing.T) { secondSnapshot := state.diagnosticSnapshot(secondPath, []string{secondPath}) var output bytes.Buffer - var writeMu sync.Mutex + writer := newProtocolWriter(&output) var workers sync.WaitGroup + errs := make(chan error, 2) workers.Add(2) go func() { defer workers.Done() - publishDiagnosticSnapshot(&output, &writeMu, state, firstSnapshot) + errs <- publishDiagnosticSnapshot(writer, state, firstSnapshot) }() go func() { defer workers.Done() - publishDiagnosticSnapshot(&output, &writeMu, state, secondSnapshot) + errs <- publishDiagnosticSnapshot(writer, state, secondSnapshot) }() workers.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("publish diagnostics: %v", err) + } + } published := collectPublishedDiagnostics(t, output.Bytes()) if len(published[pathToURI(firstPath)]) != 1 || len(published[pathToURI(secondPath)]) != 1 { diff --git a/internal/lsp/state.go b/internal/lsp/state.go index c4d0b44..84c65de 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -17,6 +17,7 @@ type ServerState struct { mu sync.Mutex publishMu sync.Mutex diagWG sync.WaitGroup + diagErr error RootDir string Cache map[string]string LastCtx *project.CompilerContext @@ -231,7 +232,7 @@ func (s *ServerState) currentCompiledModule(filePath string) (*project.CompilerC return s.recompileLocked(filePath) } -func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Duration, publish func()) { +func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Duration, publish func() error) { if s == nil || publish == nil { return } @@ -246,20 +247,29 @@ func (s *ServerState) scheduleDiagnosticRefresh(filePath string, delay time.Dura // burst of keystrokes collapses into one recompile instead of one per edit. time.Sleep(delay) s.mu.Lock() - if s.diagVersion[filePath] != version { + if s.diagVersion[filePath] != version || s.diagErr != nil { s.mu.Unlock() return } s.mu.Unlock() - publish() + if err := publish(); err != nil { + s.mu.Lock() + if s.diagErr == nil { + s.diagErr = err + } + s.mu.Unlock() + } }) } -func (s *ServerState) waitForScheduledDiagnostics() { +func (s *ServerState) waitForScheduledDiagnostics() error { if s == nil { - return + return nil } s.diagWG.Wait() + s.mu.Lock() + defer s.mu.Unlock() + return s.diagErr } func (s *ServerState) seedReusableModules(ctx *project.CompilerContext, dirtyFiles map[string]struct{}) map[string]phase.Phase { From b019fafcb4d3d1626a693c56720ba2ff4cf19b9d Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:49:02 +0600 Subject: [PATCH 06/12] Isolate diagnostic output formats Give each diagnostic emitter a dedicated color logger and route syntax highlighting and summaries through the same format owner. Logger output methods centralize repeated render-and-write behavior across emitter, highlighter, and summary paths. --- internal/diagnostics/bag.go | 26 +++++----- internal/diagnostics/bag_test.go | 46 ++++++++++++++++- internal/diagnostics/emitter.go | 49 ++++++++++--------- internal/diagnostics/syntax_highlighter.go | 13 ++--- .../diagnostics/syntax_highlighter_test.go | 2 +- pkg/colors/logger.go | 19 +++++++ 6 files changed, 109 insertions(+), 46 deletions(-) diff --git a/internal/diagnostics/bag.go b/internal/diagnostics/bag.go index 63da322..00a1ec6 100644 --- a/internal/diagnostics/bag.go +++ b/internal/diagnostics/bag.go @@ -324,18 +324,18 @@ func sortDiagnostics(diagnostics []*Diagnostic) { func (db *DiagnosticBag) EmitAll() { emitter := NewEmitter(os.Stderr) - db.emitFiltered(emitter, os.Stderr, func(*Diagnostic) bool { return true }) + db.emitFiltered(emitter, func(*Diagnostic) bool { return true }) } // EmitErrors prints only error diagnostics and an error-only summary. func (db *DiagnosticBag) EmitErrors() { emitter := NewEmitter(os.Stderr) - db.emitFiltered(emitter, os.Stderr, func(diag *Diagnostic) bool { + db.emitFiltered(emitter, func(diag *Diagnostic) bool { return diag != nil && diag.Severity == Error }) } -func (db *DiagnosticBag) emitFiltered(emitter *Emitter, w io.Writer, keep func(*Diagnostic) bool) { +func (db *DiagnosticBag) emitFiltered(emitter *Emitter, keep func(*Diagnostic) bool) { diagnostics := db.Diagnostics() filtered := diagnostics[:0] @@ -360,7 +360,7 @@ func (db *DiagnosticBag) emitFiltered(emitter *Emitter, w io.Writer, keep func(* emitter.Emit(diag) } - printSummary(w, errors, warnings) + printSummary(emitter.writer, emitter.logger, errors, warnings) } // EmitAllToString emits all diagnostics to a string with ANSI codes, using provided source cache @@ -374,31 +374,29 @@ func (db *DiagnosticBag) EmitAllToHTML() string { } func (db *DiagnosticBag) emitAllToStringWithFormat(format colors.LogFormat) string { - prevFormat := colors.CurrentLogFormat() - colors.SetLogFormat(format) - defer colors.SetLogFormat(prevFormat) - var buf bytes.Buffer + logger := colors.NewLogger(format) emitter := &Emitter{ cache: db.sourceCache, writer: &buf, - highlighter: NewSyntaxHighlighter(true), + logger: logger, + highlighter: NewSyntaxHighlighter(true, logger), } - db.emitFiltered(emitter, &buf, func(*Diagnostic) bool { return true }) + db.emitFiltered(emitter, func(*Diagnostic) bool { return true }) return buf.String() } -func printSummary(w io.Writer, errorCount, warnCount int) { +func printSummary(w io.Writer, logger *colors.Logger, errorCount, warnCount int) { if errorCount > 0 { - colors.RED.Fprintf(w, compileFailedMsg, errorCount) + logger.Fprintf(w, colors.RED, compileFailedMsg, errorCount) if warnCount > 0 { - colors.RED.Fprintf(w, andWarningMsg, warnCount) + logger.Fprintf(w, colors.RED, andWarningMsg, warnCount) } fmt.Fprintln(w) } else if warnCount > 0 { - colors.ORANGE.Fprintf(w, compileSuccessWithWarning, warnCount) + logger.Fprintf(w, colors.ORANGE, compileSuccessWithWarning, warnCount) } } diff --git a/internal/diagnostics/bag_test.go b/internal/diagnostics/bag_test.go index 666eb7d..c57b90e 100644 --- a/internal/diagnostics/bag_test.go +++ b/internal/diagnostics/bag_test.go @@ -7,6 +7,7 @@ import ( "testing" "compiler/internal/phase" + "compiler/pkg/colors" ) func TestBeginPhaseReplacesOnlySelectedGroup(t *testing.T) { @@ -171,14 +172,55 @@ func TestEmitAllToHTMLRendersDirectHTML(t *testing.T) { } } +func TestConcurrentStringFormatsRemainIsolated(t *testing.T) { + bag := NewDiagnosticBag() + bag.Add(NewError("")) + + const iterations = 500 + start := make(chan struct{}) + errs := make(chan string, 2) + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + <-start + for range iterations { + out := bag.EmitAllToString() + if !strings.Contains(out, "\033[") || strings.Contains(out, " | " with consistent width/color. func (e *Emitter) printGutter(line int) { - colors.GREY.Fprintf(e.writer, GUTTER_FMT, e.currentLineNumWidth, line) + e.logger.Fprintf(e.writer, colors.GREY, GUTTER_FMT, e.currentLineNumWidth, line) } func (e *Emitter) printCurrentGutter(line int) { - colors.WHITE.Fprintf(e.writer, GUTTER_FMT, e.currentLineNumWidth, line) + e.logger.Fprintf(e.writer, colors.WHITE, GUTTER_FMT, e.currentLineNumWidth, line) } func (e *Emitter) printBlankGutter() { - colors.GREY.Fprintf(e.writer, GUTTER_BLANK, e.currentLineNumWidth, "") + e.logger.Fprintf(e.writer, colors.GREY, GUTTER_BLANK, e.currentLineNumWidth, "") } func (e *Emitter) printAddedGutter(color colors.COLOR) { if color == "" { color = colors.GREEN } - color.Fprintf(e.writer, GUTTER_BLANK, e.currentLineNumWidth, "+") + e.logger.Fprintf(e.writer, color, GUTTER_BLANK, e.currentLineNumWidth, "+") } func (e *Emitter) printRemovedGutter(color colors.COLOR) { if color == "" { color = colors.RED } - color.Fprintf(e.writer, GUTTER_BLANK, e.currentLineNumWidth, "-") + e.logger.Fprintf(e.writer, color, GUTTER_BLANK, e.currentLineNumWidth, "-") } func (e *Emitter) printPipeOnly() { @@ -258,7 +261,7 @@ func (e *Emitter) printPrevNonEmptyLine(filepath string, line int) { func (e *Emitter) printLocationHeader(filepath string, line int, col int) { indent := e.currentLineNumWidth + 1 - colors.BLUE.Fprintf(e.writer, "%*s--> %s:%d:%d\n", indent, "", filepath, line, col) + e.logger.Fprintf(e.writer, colors.BLUE, "%*s--> %s:%d:%d\n", indent, "", filepath, line, col) } func (e *Emitter) printSideNotePrefix() { @@ -356,7 +359,7 @@ func (e *Emitter) Emit(diag *Diagnostic) { } else if line > lastLine+1 { // CASE 2: Same file, skip in lines -> Print aligned '...' without the pipe // This aligns the dots perfectly with where the line numbers sit - colors.GREY.Fprintf(e.writer, "%*s\n", e.currentLineNumWidth, "...") + e.logger.Fprintf(e.writer, colors.GREY, "%*s\n", e.currentLineNumWidth, "...") } // Clean context code block with customizable tilde/caret markings @@ -444,10 +447,10 @@ func (e *Emitter) printPeeperSnippetBlock(filepath string, label Label, severity } fmt.Fprint(e.writer, strings.Repeat(" ", padding)) - underlineColor.Fprint(e.writer, strings.Repeat(underlineChar, length)) + e.logger.Fprint(e.writer, underlineColor, strings.Repeat(underlineChar, length)) if l == endLine && label.Message != "" { - underlineColor.Fprintf(e.writer, " %s", label.Message) + e.logger.Fprintf(e.writer, underlineColor, " %s", label.Message) } fmt.Fprintln(e.writer) } @@ -521,9 +524,9 @@ func (e *Emitter) printDiagnosticHeader(diag *Diagnostic) { color = colors.BOLD_PURPLE } - color.Fprintf(e.writer, "[%s]", diag.Code) + e.logger.Fprintf(e.writer, color, "[%s]", diag.Code) fmt.Fprint(e.writer, ": ") - color.Fprintln(e.writer, diag.Message) + e.logger.Fprintln(e.writer, color, diag.Message) } func (e *Emitter) printCodeHint(ctx labelContext) { @@ -638,19 +641,19 @@ func (e *Emitter) printInlineReplacementHint(ctx labelContext, hint *CodeHint) b relPath := e.diffDisplayPath(ctx.filepath) e.printBlankGutter() - colors.RED.Fprintf(e.writer, " --- a/%s\n", relPath) + e.logger.Fprintf(e.writer, colors.RED, " --- a/%s\n", relPath) e.printBlankGutter() - colors.GREEN.Fprintf(e.writer, " +++ b/%s\n", relPath) + e.logger.Fprintf(e.writer, colors.GREEN, " +++ b/%s\n", relPath) e.printBlankGutter() - colors.GREY.Fprintf(e.writer, " @@ line %d @@\n", ctx.line) + e.logger.Fprintf(e.writer, colors.GREY, " @@ line %d @@\n", ctx.line) e.printBlankGutter() - colors.RED.Fprint(e.writer, "- ") + e.logger.Fprint(e.writer, colors.RED, "- ") e.printLineWithColoredSpan(expandedSourceLine, oldAbsStart, oldDiffLen, colors.RED) fmt.Fprintln(e.writer) e.printBlankGutter() - colors.GREEN.Fprint(e.writer, "+ ") + e.logger.Fprint(e.writer, colors.GREEN, "+ ") e.printLineWithColoredSpan(replacementLine, newAbsStart, newDiffLen, colors.GREEN) fmt.Fprintln(e.writer) @@ -692,7 +695,7 @@ func (e *Emitter) printLineWithColoredSpan(line string, start, length int, spanC } e.highlighter.HighlightWithColor(line[:start], e.writer) - spanColor.Fprint(e.writer, line[start:end]) + e.logger.Fprint(e.writer, spanColor, line[start:end]) e.highlighter.HighlightWithColor(line[end:], e.writer) } @@ -755,9 +758,9 @@ func (e *Emitter) printCodeHintLabelLine(label CodeHintLabel, severity Severity) color = colors.BLUE } - color.Fprint(e.writer, strings.Repeat("~", length)) + e.logger.Fprint(e.writer, color, strings.Repeat("~", length)) if label.Message != "" { - color.Fprintf(e.writer, " %s", label.Message) + e.logger.Fprintf(e.writer, color, " %s", label.Message) } fmt.Fprintln(e.writer) } @@ -773,16 +776,16 @@ func (e *Emitter) printText(text DiagnosticText) { e.printSideNotePrefix() if text.Kind != "" { - color.Fprintf(e.writer, "= %s: ", text.Kind) + e.logger.Fprintf(e.writer, color, "= %s: ", text.Kind) } else { - color.Fprintf(e.writer, "= ") + e.logger.Fprintf(e.writer, color, "= ") } fmt.Fprintln(e.writer, text.Message) } func (e *Emitter) printSuggestionHeader() { e.printSideNotePrefix() - colors.GREEN.Fprint(e.writer, "= suggestion:") + e.logger.Fprint(e.writer, colors.GREEN, "= suggestion:") fmt.Fprintln(e.writer) } diff --git a/internal/diagnostics/syntax_highlighter.go b/internal/diagnostics/syntax_highlighter.go index f419edc..7d46c2a 100644 --- a/internal/diagnostics/syntax_highlighter.go +++ b/internal/diagnostics/syntax_highlighter.go @@ -41,11 +41,12 @@ var highlightNumberPattern = regexp.MustCompile("^" + numeric.NumberTokenPattern // SyntaxHighlighter provides syntax highlighting for Peeper code snippets type SyntaxHighlighter struct { enabled bool + logger *colors.Logger } // NewSyntaxHighlighter creates a new syntax highlighter -func NewSyntaxHighlighter(enabled bool) *SyntaxHighlighter { - return &SyntaxHighlighter{enabled: enabled} +func NewSyntaxHighlighter(enabled bool, logger *colors.Logger) *SyntaxHighlighter { + return &SyntaxHighlighter{enabled: enabled, logger: logger} } // Enable turns on syntax highlighting @@ -204,7 +205,7 @@ func (sh *SyntaxHighlighter) HighlightLine(line string) string { var result strings.Builder for _, token := range tokens { - token.Color.Fprint(&result, token.Text) + sh.logger.Fprint(&result, token.Color, token.Text) } return result.String() @@ -220,7 +221,7 @@ func (sh *SyntaxHighlighter) HighlightWithColor(line string, writer io.Writer) { tokens := sh.Highlight(line) for _, token := range tokens { - token.Color.Fprint(writer, token.Text) + sh.logger.Fprint(writer, token.Color, token.Text) } } @@ -229,7 +230,7 @@ func (sh *SyntaxHighlighter) HighlightWithColor(line string, writer io.Writer) { func (sh *SyntaxHighlighter) HighlightWithBaseColor(line string, writer io.Writer, base colors.COLOR) { if !sh.enabled { if base != "" { - base.Fprint(writer, line) + sh.logger.Fprint(writer, base, line) } else { fmt.Fprint(writer, line) } @@ -242,6 +243,6 @@ func (sh *SyntaxHighlighter) HighlightWithBaseColor(line string, writer io.Write if base != "" && color == colors.WHITE { color = base } - color.Fprint(writer, token.Text) + sh.logger.Fprint(writer, color, token.Text) } } diff --git a/internal/diagnostics/syntax_highlighter_test.go b/internal/diagnostics/syntax_highlighter_test.go index 2dfe004..21ded5f 100644 --- a/internal/diagnostics/syntax_highlighter_test.go +++ b/internal/diagnostics/syntax_highlighter_test.go @@ -7,7 +7,7 @@ import ( ) func TestSyntaxHighlighterKeepsPrefixedAndScientificNumbersTogether(t *testing.T) { - sh := NewSyntaxHighlighter(true) + sh := NewSyntaxHighlighter(true, colors.NewLogger(colors.LogFormatANSI)) tokens := sh.Highlight(`0b4234 0x1f 0o7 1.5e2`) wantText := []string{"0b4234", " ", "0x1f", " ", "0o7", " ", "1.5e2"} wantColor := []colors.COLOR{ diff --git a/pkg/colors/logger.go b/pkg/colors/logger.go index 8365083..5aedc96 100644 --- a/pkg/colors/logger.go +++ b/pkg/colors/logger.go @@ -5,6 +5,7 @@ package colors import ( "fmt" "html" + "io" "strings" "sync" ) @@ -77,6 +78,12 @@ func ParseLogFormat(raw string) (LogFormat, error) { } } +func NewLogger(format LogFormat) *Logger { + logger := &Logger{} + logger.SetFormat(format) + return logger +} + func SetLogFormat(format LogFormat) { defaultLogger.SetFormat(format) } @@ -133,6 +140,18 @@ func (l *Logger) Render(color COLOR, text string) string { } } +func (l *Logger) Fprintf(w io.Writer, color COLOR, format string, args ...any) { + fmt.Fprint(w, l.Render(color, fmt.Sprintf(format, args...))) +} + +func (l *Logger) Fprintln(w io.Writer, color COLOR, args ...any) { + fmt.Fprint(w, l.Render(color, fmt.Sprintln(args...))) +} + +func (l *Logger) Fprint(w io.Writer, color COLOR, args ...any) { + fmt.Fprint(w, l.Render(color, fmt.Sprint(args...))) +} + func renderHTML(color COLOR, text string) string { escaped := formatHTMLText(text, false) if color == "" { From e000a2696478d95ae5e4aacebe0b951f34370c3e Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:53:42 +0600 Subject: [PATCH 07/12] Move semantic scopes into symbols Make symbols.Scope the concrete owner used by project and compiler phases, remove runtime scope assertions, and delete the obsolete table package without an alias. --- internal/ir/hir/lower/lower_interface.go | 4 +- internal/ir/hir/lower/lower_types.go | 3 +- internal/ir/hir/lower/module_lower.go | 37 +++++++++---------- internal/ir/mir/module_lower.go | 3 +- internal/ir/mir/module_lower_test.go | 5 +-- internal/lsp/completion.go | 3 +- internal/lsp/cursor.go | 7 +--- internal/problems/problems.go | 4 +- internal/project/context.go | 11 +++--- internal/project/export_fingerprint_test.go | 3 +- internal/project/modules.go | 7 ++-- internal/project/modules_test.go | 4 +- internal/semantics/collector/collector.go | 7 ++-- internal/semantics/consteval/consteval.go | 7 ++-- .../semantics/definiteinit/initialization.go | 9 ++--- internal/semantics/ownership/expr.go | 21 +++++------ internal/semantics/ownership/ownership.go | 21 +++++------ .../semantics/ownership/ownership_test.go | 5 +-- internal/semantics/ownership/reference.go | 11 +++--- internal/semantics/place/addressable.go | 7 ++-- internal/semantics/place/origin.go | 3 +- internal/semantics/place/origin_test.go | 15 ++++---- internal/semantics/resolver/resolver.go | 21 +++++------ internal/semantics/resolver/suggest.go | 6 +-- .../semantics/{table => symbols}/scope.go | 34 ++++++++--------- .../{table => symbols}/scope_test.go | 25 ++++++------- internal/semantics/symbols/symbol.go | 2 +- internal/semantics/symbols/symbol_test.go | 8 ++++ .../semantics/typechecker/assignability.go | 5 +-- internal/semantics/typechecker/check_call.go | 19 +++++----- internal/semantics/typechecker/check_expr.go | 27 +++++++------- internal/semantics/typechecker/check_fn.go | 7 ++-- internal/semantics/typechecker/check_stmt.go | 17 ++++----- internal/semantics/typechecker/typechecker.go | 3 +- .../semantics/typechecker/typechecker_test.go | 5 +-- 35 files changed, 177 insertions(+), 199 deletions(-) rename internal/semantics/{table => symbols}/scope.go (62%) rename internal/semantics/{table => symbols}/scope_test.go (78%) diff --git a/internal/ir/hir/lower/lower_interface.go b/internal/ir/hir/lower/lower_interface.go index 2f61af2..b7eab47 100644 --- a/internal/ir/hir/lower/lower_interface.go +++ b/internal/ir/hir/lower/lower_interface.go @@ -4,11 +4,11 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/ir" "compiler/internal/project" - "compiler/internal/semantics/table" + "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" ) -func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, expr ast.Expr, expectedType typeinfo.Type) ir.Expr { +func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, expectedType typeinfo.Type) ir.Expr { if expectedType == nil { return nil } diff --git a/internal/ir/hir/lower/lower_types.go b/internal/ir/hir/lower/lower_types.go index c56a46d..56dda99 100644 --- a/internal/ir/hir/lower/lower_types.go +++ b/internal/ir/hir/lower/lower_types.go @@ -4,7 +4,6 @@ import ( "compiler/internal/ir" "compiler/internal/project" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -138,7 +137,7 @@ func internRuntimeType(types *ir.TypeTable, t typeinfo.Type) ir.TypeID { // lowerer can collapse source-level aliases before runtime layout work. // Called only from loweredRuntimeType; lives here to avoid importing table // from the leaf typeinfo package. -func resolveNamedType(scope *table.Scope, t typeinfo.Type) typeinfo.Type { +func resolveNamedType(scope *symbols.Scope, t typeinfo.Type) typeinfo.Type { if scope == nil || t == nil { return t } diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index e079537..a822d20 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -15,7 +15,6 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/internal/source" "compiler/pkg/numeric" @@ -51,7 +50,7 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu resolvedFnType, _ := fnType.(*typeinfo.FuncType) emittedName, _ := callableName(module, sym) if fn.Body == nil { - params, returnType := lowerExternSignature(ctx, module, sym.Scope.(*table.Scope), fn.ParamsWithReceiver(), fn.ReturnType, resolvedFnType) + params, returnType := lowerExternSignature(ctx, module, sym.Scope, fn.ParamsWithReceiver(), fn.ReturnType, resolvedFnType) out.Externs = append(out.Externs, hir.Extern{ Name: emittedName, Params: params, @@ -71,7 +70,7 @@ func GenerateHIR(ctx *project.CompilerContext, module *project.Module) *hir.Modu return out } -func lowerExternSignature(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, params []ast.Param, fallbackReturnType ast.TypeExpr, resolvedFnType *typeinfo.FuncType) ([]ir.Param, ir.TypeID) { +func lowerExternSignature(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, params []ast.Param, fallbackReturnType ast.TypeExpr, resolvedFnType *typeinfo.FuncType) ([]ir.Param, ir.TypeID) { loweredParams := make([]ir.Param, 0, len(params)) for i, param := range params { name := "" @@ -102,7 +101,7 @@ func lowerASTFunctionNamed(ctx *project.CompilerContext, module *project.Module, if sym == nil || fn == nil || fn.Body == nil || sym.Scope == nil { return nil } - funcScope := sym.Scope.(*table.Scope) + funcScope := sym.Scope retType, ok := symbols.GetSymbolType(sym) if ok { if fnType, ok := retType.(*typeinfo.FuncType); ok && fnType != nil { @@ -144,7 +143,7 @@ func lowerASTFunctionNamed(ctx *project.CompilerContext, module *project.Module, return hirFn } -func appendBlock(module *project.Module, parentScope *table.Scope, out *hir.Block, block *ast.BlockStmt, returnType typeinfo.Type, ctx *project.CompilerContext) { +func appendBlock(module *project.Module, parentScope *symbols.Scope, out *hir.Block, block *ast.BlockStmt, returnType typeinfo.Type, ctx *project.CompilerContext) { if out == nil || block == nil { return } @@ -161,7 +160,7 @@ func appendBlock(module *project.Module, parentScope *table.Scope, out *hir.Bloc } } -func appendStmt(module *project.Module, scope *table.Scope, out *hir.Block, stmt ast.Stmt, returnType typeinfo.Type, ctx *project.CompilerContext) { +func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, stmt ast.Stmt, returnType typeinfo.Type, ctx *project.CompilerContext) { switch node := stmt.(type) { case nil: return @@ -272,7 +271,7 @@ func appendStmt(module *project.Module, scope *table.Scope, out *hir.Block, stmt } } -func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, expr ast.Expr) *ir.Place { +func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr) *ir.Place { if selector, ok := expr.(*ast.SelectorExpr); ok && selector != nil && selector.Expr != nil && selector.Name != nil { baseType := exprResolvedType(module, selector.Expr) if field, fieldIndex, ok := typeinfo.LookupStructField(loweredRuntimeType(module, baseType, nil), selector.Name.Name); ok { @@ -322,7 +321,7 @@ func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *tab } } -func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, expr ast.Expr, resultType typeinfo.Type, typeID ir.TypeID) ir.Expr { +func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, resultType typeinfo.Type, typeID ir.TypeID) ir.Expr { target, _, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(resultType)) if !reference { return &ir.InvalidExpr{Message: "reference lowering requires reference type", Type: ir.InvalidType, Location: ast.LocOf(expr)} @@ -352,7 +351,7 @@ func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, s return &ir.AddrOf{Place: value, Type: typeID, Location: ast.LocOf(expr)} } -func lowerImplicitReferenceValue(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, expr ast.Expr, resultType typeinfo.Type) ir.Expr { +func lowerImplicitReferenceValue(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, resultType typeinfo.Type) ir.Expr { typeID := loweredTypeID(ctx, module, resultType) if _, _, borrowed := typeinfo.ReferenceTarget(typeinfo.Underlying(exprResolvedType(module, expr))); borrowed { return lowerASTExpr(ctx, module, scope, expr, nil) @@ -360,7 +359,7 @@ func lowerImplicitReferenceValue(ctx *project.CompilerContext, module *project.M return lowerReferenceValue(ctx, module, scope, expr, resultType, typeID) } -func lowerElse(module *project.Module, scope *table.Scope, stmt ast.Stmt, returnType typeinfo.Type, ctx *project.CompilerContext) hir.Stmt { +func lowerElse(module *project.Module, scope *symbols.Scope, stmt ast.Stmt, returnType typeinfo.Type, ctx *project.CompilerContext) hir.Stmt { switch node := stmt.(type) { case *ast.BlockStmt: block := &hir.Block{Stmts: make([]hir.Stmt, 0), NodeID: hir.NodeID(node.ID()), Location: ast.LocOf(node)} @@ -395,7 +394,7 @@ func lowerElse(module *project.Module, scope *table.Scope, stmt ast.Stmt, return // lowerASTExpr directly lowers an AST expression to an IR expression using // the module context's resolved expression types side-table. -func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, expr ast.Expr, expectedType typeinfo.Type) (result ir.Expr) { +func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, expectedType typeinfo.Type) (result ir.Expr) { if expr == nil { return &ir.InvalidExpr{Message: "nil expression", Type: ir.InvalidType} } @@ -673,7 +672,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t } } -func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, call *ast.CallExpr, op symbols.CompilerOp) ir.Expr { +func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, call *ast.CallExpr, op symbols.CompilerOp) ir.Expr { fnType, _ := exprResolvedType(module, call.Callee).(*typeinfo.FuncType) if fnType == nil || len(fnType.Params) != 1 { return &ir.InvalidExpr{Message: "collection function type missing", Type: ir.InvalidType, Location: ast.LocOf(call)} @@ -738,7 +737,7 @@ func optionalSomeInnerType(module *project.Module, expectedType, resolvedType ty } } -func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, selector *ast.SelectorExpr, call *ast.CallExpr) ir.Expr { +func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr) ir.Expr { if module == nil || selector == nil || selector.Expr == nil || selector.Name == nil { return &ir.InvalidExpr{Message: "invalid selector call", Type: ir.InvalidType} } @@ -802,7 +801,7 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul } } -func lowerSelectorExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, selector *ast.SelectorExpr) ir.Expr { +func lowerSelectorExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, selector *ast.SelectorExpr) ir.Expr { if module == nil || selector == nil || selector.Expr == nil || selector.Name == nil { return &ir.InvalidExpr{Message: "invalid selector", Type: ir.InvalidType} } @@ -829,7 +828,7 @@ func lowerSelectorExpr(ctx *project.CompilerContext, module *project.Module, sco return &ir.InvalidExpr{Message: "selector lowering not implemented", Type: ir.InvalidType, Location: ast.LocOf(selector)} } -func lowerIndexExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, node *ast.IndexExpr) ir.Expr { +func lowerIndexExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.IndexExpr) ir.Expr { if module == nil || node == nil || node.Expr == nil || node.Index == nil { return &ir.InvalidExpr{Message: "invalid index", Type: ir.InvalidType, Location: ast.LocOf(node)} } @@ -861,7 +860,7 @@ func lowerIndexExpr(ctx *project.CompilerContext, module *project.Module, scope return &ir.Load{Place: lowerPlace(ctx, module, scope, node), NodeID: ir.NodeID(node.ID()), Location: ast.LocOf(node)} } -func lowerStructLiteralExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, node *ast.StructLit) ir.Expr { +func lowerStructLiteralExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.StructLit) ir.Expr { if module == nil || node == nil { return &ir.InvalidExpr{Message: "invalid struct literal", Type: ir.InvalidType, Location: ast.LocOf(node)} } @@ -892,7 +891,7 @@ func lowerStructLiteralExpr(ctx *project.CompilerContext, module *project.Module } } -func lowerArrayLiteralExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, node *ast.ArrayLit) ir.Expr { +func lowerArrayLiteralExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.ArrayLit) ir.Expr { if module == nil || node == nil { return &ir.InvalidExpr{Message: "invalid array literal", Type: ir.InvalidType, Location: ast.LocOf(node)} } @@ -913,7 +912,7 @@ func lowerArrayLiteralExpr(ctx *project.CompilerContext, module *project.Module, } } -func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, node *ast.CallExpr, op symbols.CompilerOp) ir.Expr { +func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, op symbols.CompilerOp) ir.Expr { fnType, _ := typeinfo.Underlying(exprResolvedType(module, node.Callee)).(*typeinfo.FuncType) if fnType == nil || len(fnType.Params) != len(node.Args) || len(node.Args) < 2 { return &ir.InvalidExpr{Message: "dynamic-array operation type missing", Type: ir.InvalidType, Location: ast.LocOf(node)} @@ -954,7 +953,7 @@ func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Mo return out } -func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, node *ast.CallExpr) ir.Expr { +func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr) ir.Expr { if len(node.Args) < 1 || len(node.Args) > 2 { return &ir.InvalidExpr{Message: "alloc requires 1 or 2 arguments", Type: ir.InvalidType, Location: ast.LocOf(node)} } diff --git a/internal/ir/mir/module_lower.go b/internal/ir/mir/module_lower.go index ff70951..f6e6b3c 100644 --- a/internal/ir/mir/module_lower.go +++ b/internal/ir/mir/module_lower.go @@ -10,7 +10,6 @@ import ( "compiler/internal/ir/hir" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/internal/source" ) @@ -35,7 +34,7 @@ func (l *lowerer) isVoid(id ir.TypeID) bool { return ok && typ.Kind == ir.TypeVoid } -func GenerateMIR(in *hir.Module, graphs *cfg.Module, ownership ownershipresult.Result, scope *table.Scope, constValues map[symbols.SymbolID]constvalue.Value) *Module { +func GenerateMIR(in *hir.Module, graphs *cfg.Module, ownership ownershipresult.Result, scope *symbols.Scope, constValues map[symbols.SymbolID]constvalue.Value) *Module { if in == nil || graphs == nil { return nil } diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 9148844..03817cb 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -11,7 +11,6 @@ import ( "compiler/internal/ir/hir" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/internal/source" "compiler/pkg/peeper" @@ -343,7 +342,7 @@ func TestGenerateMIRAppliesOwnershipCleanupPlan(t *testing.T) { func TestGenerateMIRStaticDataUsesSemanticConstValues(t *testing.T) { mod := &hir.Module{Name: "test", Types: mirTypes.table} - scope := table.New(nil) + scope := symbols.NewScope(nil) sym := symbols.New("Name", symbols.SymbolConst, nil, nil) sym.BindType(&typeinfo.CStrType{}) if err := scope.Declare(sym); err != nil { @@ -369,7 +368,7 @@ func TestGenerateMIRStaticDataUsesSemanticConstValues(t *testing.T) { func TestGenerateMIRStaticDataFormatsFloatConstValues(t *testing.T) { mod := &hir.Module{Name: "test", Types: mirTypes.table} - scope := table.New(nil) + scope := symbols.NewScope(nil) sym := symbols.New("X", symbols.SymbolConst, nil, nil) sym.BindType(&typeinfo.FloatType{Bits: 64}) if err := scope.Declare(sym); err != nil { diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 4b847f2..c28be28 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -12,7 +12,6 @@ import ( "compiler/internal/project" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typechecker" "compiler/internal/semantics/typeinfo" "compiler/internal/source" @@ -378,7 +377,7 @@ func lexicalCompletionItems(module *project.Module, cursor source.Position, pref return sortCompletionItems(items) } -func completionScope(module *project.Module, line, col int) *table.Scope { +func completionScope(module *project.Module, line, col int) *symbols.Scope { scope := module.ModuleScope walkModuleAST(module, func(node ast.Node, _ ast.Node) bool { block, ok := node.(*ast.BlockStmt) diff --git a/internal/lsp/cursor.go b/internal/lsp/cursor.go index 6c5b646..3e13045 100644 --- a/internal/lsp/cursor.go +++ b/internal/lsp/cursor.go @@ -4,7 +4,6 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/project" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/internal/source" ) @@ -140,7 +139,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul } // 4. Resolve in local block/function scopes - var scope *table.Scope + var scope *symbols.Scope curr := parent for curr != nil { if block, ok := curr.(*ast.BlockStmt); ok { @@ -163,9 +162,7 @@ func resolveIdentSymbol(ident *ast.Ident, parents map[ast.NodeID]ast.Node, modul } if containingFn != nil { if sym, ok := module.ModuleScope.Lookup(containingFn.Name.Name); ok && sym != nil && sym.Scope != nil { - if fs, ok := sym.Scope.(*table.Scope); ok { - scope = fs - } + scope = sym.Scope } } } diff --git a/internal/problems/problems.go b/internal/problems/problems.go index 971ec24..63f49a7 100644 --- a/internal/problems/problems.go +++ b/internal/problems/problems.go @@ -4,7 +4,7 @@ import ( "fmt" "compiler/internal/diagnostics" - "compiler/internal/semantics/table" + "compiler/internal/semantics/symbols" "compiler/internal/source" ) @@ -34,7 +34,7 @@ func Redeclaration(message string, current, previous *source.Location) *diagnost return d } -func ReportRedeclaration(diag *diagnostics.DiagnosticBag, scope *table.Scope, err string, name string, loc *source.Location) { +func ReportRedeclaration(diag *diagnostics.DiagnosticBag, scope *symbols.Scope, err string, name string, loc *source.Location) { if diag == nil { return } diff --git a/internal/project/context.go b/internal/project/context.go index 801da7d..7bab89b 100644 --- a/internal/project/context.go +++ b/internal/project/context.go @@ -14,7 +14,6 @@ import ( "compiler/internal/phase" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/internal/target" "compiler/pkg/manifest" @@ -40,7 +39,7 @@ type CompilerContext struct { // Optional per-run metrics for benchmarks and incremental validation. Metrics *CompileMetrics // Predeclared symbols visible before user/prelude code. - GlobalScope *table.Scope + GlobalScope *symbols.Scope // Module key -> module. modules map[string]*Module @@ -271,8 +270,8 @@ func (ctx *CompilerContext) ModuleOriginForFile(filePath string) (ModuleOrigin, } // Compiler-owned names available before prelude parsing. -func predeclaredScope(compilerTarget target.Info) *table.Scope { - scope := table.New(nil) +func predeclaredScope(compilerTarget target.Info) *symbols.Scope { + scope := symbols.NewScope(nil) declarePredeclaredConst(scope, "true") declarePredeclaredConst(scope, "false") declarePredeclaredConst(scope, "none") @@ -287,7 +286,7 @@ func predeclaredScope(compilerTarget target.Info) *table.Scope { } // Add one compiler-defined constant to the root scope. -func declarePredeclaredConst(scope *table.Scope, name string) { +func declarePredeclaredConst(scope *symbols.Scope, name string) { if scope == nil || name == "" { return } @@ -307,7 +306,7 @@ func declarePredeclaredConst(scope *table.Scope, name string) { } } -func declarePredeclaredType(scope *table.Scope, name string, typ typeinfo.Type) { +func declarePredeclaredType(scope *symbols.Scope, name string, typ typeinfo.Type) { if scope == nil || name == "" || typ == nil { return } diff --git a/internal/project/export_fingerprint_test.go b/internal/project/export_fingerprint_test.go index f2eba6a..559e350 100644 --- a/internal/project/export_fingerprint_test.go +++ b/internal/project/export_fingerprint_test.go @@ -6,7 +6,6 @@ import ( "compiler/internal/constvalue" "compiler/internal/frontend/ast" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -17,7 +16,7 @@ func (*unexpectedSemanticType) Text() string { return "unexpected" } func fingerprintModule(t *testing.T, exported *symbols.Symbol, semantics *SemanticInfo) *Module { t.Helper() - scope := table.New(nil) + scope := symbols.NewScope(nil) if err := scope.Declare(exported); err != nil { t.Fatalf("declare export: %v", err) } diff --git a/internal/project/modules.go b/internal/project/modules.go index 8fdc4ee..bafe525 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -14,7 +14,6 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -77,7 +76,7 @@ type Module struct { MIR *mir.Module LLVMIR string // Top-level names visible in module. - ModuleScope *table.Scope + ModuleScope *symbols.Scope // Grouped semantic analysis metadata. Semantics *SemanticInfo // Import alias -> resolved module import. @@ -85,7 +84,7 @@ type Module struct { } type SemanticInfo struct { - BlockScopes map[ast.NodeID]*table.Scope + BlockScopes map[ast.NodeID]*symbols.Scope ResolvedSymbols map[ast.NodeID]*symbols.Symbol // ExpandedDefaultBindings marks cloned NodeIDs injected by // call-site default expansion. These idents must resolve @@ -133,7 +132,7 @@ func (m *Module) DefiningModuleKey() symbols.DefiningModuleKey { func NewSemanticInfo() *SemanticInfo { return &SemanticInfo{ - BlockScopes: make(map[ast.NodeID]*table.Scope), + BlockScopes: make(map[ast.NodeID]*symbols.Scope), ResolvedSymbols: make(map[ast.NodeID]*symbols.Symbol), ExpandedDefaultBindings: make(map[ast.NodeID]struct{}), ExprTypes: make(map[ast.NodeID]typeinfo.Type), diff --git a/internal/project/modules_test.go b/internal/project/modules_test.go index a996da8..b83778f 100644 --- a/internal/project/modules_test.go +++ b/internal/project/modules_test.go @@ -10,14 +10,14 @@ import ( "compiler/internal/ir/mir" "compiler/internal/phase" "compiler/internal/semantics/ownershipresult" - "compiler/internal/semantics/table" + "compiler/internal/semantics/symbols" ) func moduleWithArtifacts() *Module { return &Module{ Phase: phase.Backend, SemanticExportFingerprint: "semantic API", - ModuleScope: table.New(nil), + ModuleScope: symbols.NewScope(nil), Semantics: NewSemanticInfo(), TypedASTNodes: map[ast.NodeID]ast.Node{1: &ast.BadStmt{}}, HIR: &hir.Module{}, diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index 75e5727..f4644f3 100644 --- a/internal/semantics/collector/collector.go +++ b/internal/semantics/collector/collector.go @@ -7,7 +7,6 @@ import ( "compiler/internal/project" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -20,7 +19,7 @@ func (c *collector) collectModule(mod *ast.Module) { if c == nil || c.ctx == nil || c.module == nil || mod == nil { return } - c.module.ModuleScope = table.New(c.ctx.GlobalScope) + c.module.ModuleScope = symbols.NewScope(c.ctx.GlobalScope) c.module.ResetSemanticData() for alias := range c.module.Imports { if alias == "" { @@ -89,14 +88,14 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { } sym := symbols.New(fn.Name.Name, symbols.SymbolMethod, fn, ast.LocOf(fn.Name)) sym.DefiningModule = c.module.DefiningModuleKey() - sym.Scope = table.New(c.module.ModuleScope) + sym.Scope = symbols.NewScope(c.module.ModuleScope) c.module.Semantics.MethodSets[targetKey] = append(c.module.Semantics.MethodSets[targetKey], sym) c.module.Semantics.MethodSymbol[fn.ID()] = sym return } sym := symbols.New(fn.Name.Name, symbols.SymbolFunc, fn, ast.LocOf(fn.Name)) sym.DefiningModule = c.module.DefiningModuleKey() - sym.Scope = table.New(c.module.ModuleScope) + sym.Scope = symbols.NewScope(c.module.ModuleScope) if err := c.module.ModuleScope.Declare(sym); err != nil { problems.ReportRedeclaration(c.ctx.Diagnostics, c.module.ModuleScope, err.Error(), fn.Name.Name, fn.Name.Location) return diff --git a/internal/semantics/consteval/consteval.go b/internal/semantics/consteval/consteval.go index 27b4cd0..6563bb1 100644 --- a/internal/semantics/consteval/consteval.go +++ b/internal/semantics/consteval/consteval.go @@ -6,7 +6,6 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/project" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/pkg/numeric" ) @@ -58,7 +57,7 @@ func FinalizeValues(ctx *project.CompilerContext, module *project.Module) { // EvaluateExpr computes one semantic constant using expected type information // available at the query site. It is valid during and after typechecking. -func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *table.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { +func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { if ctx == nil || module == nil || expr == nil { return nil, false } @@ -79,7 +78,7 @@ func EvaluateExpr(ctx *project.CompilerContext, module *project.Module, scope *t return e.evalExpr(scope, expr, expected) } -func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *table.Scope) (constvalue.Value, bool) { +func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *symbols.Scope) (constvalue.Value, bool) { if e == nil || e.module == nil || e.module.Semantics == nil || sym == nil { return nil, false } @@ -122,7 +121,7 @@ func (e *evaluator) evalConstSymbol(sym *symbols.Symbol, scope *table.Scope) (co return value, true } -func (e *evaluator) evalExpr(scope *table.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { +func (e *evaluator) evalExpr(scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { if node, ok := expr.(*ast.StringLit); ok { typText := "str" if node.CString { diff --git a/internal/semantics/definiteinit/initialization.go b/internal/semantics/definiteinit/initialization.go index 5571f22..5641f49 100644 --- a/internal/semantics/definiteinit/initialization.go +++ b/internal/semantics/definiteinit/initialization.go @@ -6,7 +6,6 @@ import ( "compiler/internal/ir" "compiler/internal/ir/cfg" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" ) type state map[symbols.SymbolID]struct{} @@ -20,14 +19,14 @@ type site struct { cfgSite *cfg.Site stmt ast.Stmt condition ast.Expr - scope *table.Scope + scope *symbols.Scope } // Check diagnoses reads not initialized on every reachable CFG predecessor. func Check( graphs *cfg.Module, nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*table.Scope, + blockScopes map[ast.NodeID]*symbols.Scope, resolvedSymbols map[ast.NodeID]*symbols.Symbol, diag *diagnostics.DiagnosticBag, ) { @@ -50,7 +49,7 @@ func analyzeFunction( fn *ast.FnDecl, graph *cfg.Graph, nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*table.Scope, + blockScopes map[ast.NodeID]*symbols.Scope, resolvedSymbols map[ast.NodeID]*symbols.Symbol, diag *diagnostics.DiagnosticBag, ) *functionResult { @@ -119,7 +118,7 @@ func indexSites( fn *ast.FnDecl, graph *cfg.Graph, nodes map[ast.NodeID]ast.Node, - blockScopes map[ast.NodeID]*table.Scope, + blockScopes map[ast.NodeID]*symbols.Scope, ) (map[cfg.SiteID]*site, []cfg.SiteID, map[symbols.SymbolID]string) { sites := make(map[cfg.SiteID]*site) order := make([]cfg.SiteID, 0) diff --git a/internal/semantics/ownership/expr.go b/internal/semantics/ownership/expr.go index 77b2f06..01b513d 100644 --- a/internal/semantics/ownership/expr.go +++ b/internal/semantics/ownership/expr.go @@ -8,7 +8,6 @@ import ( "compiler/internal/ir" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -21,7 +20,7 @@ const ( ) func (a *analyzer) checkExpr( - scope *table.Scope, + scope *symbols.Scope, expr ast.Expr, st state, use useKind, @@ -122,7 +121,7 @@ func (a *analyzer) expandedDefaultBinding(ident *ast.Ident) (place.Binding, bool } func (a *analyzer) checkAddressExpr( - scope *table.Scope, + scope *symbols.Scope, expr *ast.AddressExpr, st state, loans *loanContext, @@ -144,7 +143,7 @@ func storageAccessForUse(typ typeinfo.Type, use useKind) storageAccess { return storageRead } -func (a *analyzer) checkIdent(scope *table.Scope, ident *ast.Ident, st state, use useKind) { +func (a *analyzer) checkIdent(scope *symbols.Scope, ident *ast.Ident, st state, use useKind) { if scope == nil || ident == nil { return } @@ -189,7 +188,7 @@ func (a *analyzer) checkIdent(scope *table.Scope, ident *ast.Ident, st state, us } func (a *analyzer) checkSelector( - scope *table.Scope, + scope *symbols.Scope, selector *ast.SelectorExpr, st state, use useKind, @@ -227,7 +226,7 @@ func (a *analyzer) planProjectionBaseDrop(projection, base ast.Expr) bool { return false } -func (a *analyzer) checkCall(scope *table.Scope, call *ast.CallExpr, st state, loans *loanContext) { +func (a *analyzer) checkCall(scope *symbols.Scope, call *ast.CallExpr, st state, loans *loanContext) { if call == nil { return } @@ -271,7 +270,7 @@ func (a *analyzer) checkCall(scope *table.Scope, call *ast.CallExpr, st state, l } func (a *analyzer) checkMethodCall( - scope *table.Scope, + scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr, st state, @@ -301,7 +300,7 @@ func (a *analyzer) checkMethodCall( } func (a *analyzer) checkCallArgument( - scope *table.Scope, + scope *symbols.Scope, arg ast.Expr, paramType typeinfo.Type, call *ast.CallExpr, @@ -355,7 +354,7 @@ func (a *analyzer) exprType(expr ast.Expr) typeinfo.Type { return a.module.Semantics.ExprTypes[expr.ID()] } -func (a *analyzer) updatePointerSymbol(sym *symbols.Symbol, scope *table.Scope, value ast.Expr, st state) { +func (a *analyzer) updatePointerSymbol(sym *symbols.Symbol, scope *symbols.Scope, value ast.Expr, st state) { if sym == nil || st.pointers == nil { return } @@ -375,7 +374,7 @@ func (a *analyzer) updatePointerSymbol(sym *symbols.Symbol, scope *table.Scope, delete(st.pointers, sym) } -func (a *analyzer) checkPointerEscape(scope *table.Scope, expr ast.Expr, st state) { +func (a *analyzer) checkPointerEscape(scope *symbols.Scope, expr ast.Expr, st state) { if expr == nil { return } @@ -391,7 +390,7 @@ func (a *analyzer) checkPointerEscape(scope *table.Scope, expr ast.Expr, st stat } } -func (a *analyzer) pointerOrigin(scope *table.Scope, expr ast.Expr, st state) (pointerOrigin, bool) { +func (a *analyzer) pointerOrigin(scope *symbols.Scope, expr ast.Expr, st state) (pointerOrigin, bool) { switch e := expr.(type) { case *ast.AddressExpr: if e.Mode != ast.AddressRaw { diff --git a/internal/semantics/ownership/ownership.go b/internal/semantics/ownership/ownership.go index e03fd11..db96b93 100644 --- a/internal/semantics/ownership/ownership.go +++ b/internal/semantics/ownership/ownership.go @@ -12,7 +12,6 @@ import ( "compiler/internal/semantics/ownershipresult" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -20,7 +19,7 @@ type site struct { cfgSite *cfg.Site stmt ast.Stmt block *ast.BlockStmt - scope *table.Scope + scope *symbols.Scope } type analyzer struct { @@ -31,7 +30,7 @@ type analyzer struct { order []cfg.SiteID cleanup *ownershipresult.CleanupPlan function *ast.FnDecl - functionScope *table.Scope + functionScope *symbols.Scope reportedJoin map[cfg.SiteID]bool inStates map[cfg.SiteID]state referenceLiveIn map[cfg.SiteID]map[*symbols.Symbol]ast.Node @@ -91,7 +90,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult if sym == nil { continue } - scope, _ := sym.Scope.(*table.Scope) + scope := sym.Scope graph := module.CFG.Function(ir.NodeID(node.ID())) if graph != nil { checkFunction(ctx, module, node, scope, graph, result[graph.NodeID]) @@ -101,7 +100,7 @@ func Check(ctx *project.CompilerContext, module *project.Module) ownershipresult return result } -func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast.FnDecl, scope *table.Scope, cfgFn *cfg.Graph, cleanup *ownershipresult.CleanupPlan) { +func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast.FnDecl, scope *symbols.Scope, cfgFn *cfg.Graph, cleanup *ownershipresult.CleanupPlan) { if ctx == nil || module == nil || module.Semantics == nil || fn == nil || fn.Body == nil || scope == nil || cfgFn == nil || cleanup == nil { return } @@ -119,7 +118,7 @@ func checkFunction(ctx *project.CompilerContext, module *project.Module, fn *ast }).run() } -func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *table.Scope) (map[cfg.SiteID]*site, []cfg.SiteID) { +func indexSites(module *project.Module, cfgFn *cfg.Graph, scope *symbols.Scope) (map[cfg.SiteID]*site, []cfg.SiteID) { sites := make(map[cfg.SiteID]*site) order := make([]cfg.SiteID, 0) if module == nil || module.Semantics == nil || cfgFn == nil || scope == nil { @@ -303,7 +302,7 @@ func (a *analyzer) applyBlockExit(node *site, st state, loans *loanContext) { clearScopeOwnership(node.scope, st) } -func clearScopeOwnership(scope *table.Scope, st state) { +func clearScopeOwnership(scope *symbols.Scope, st state) { if scope == nil { return } @@ -315,7 +314,7 @@ func clearScopeOwnership(scope *table.Scope, st state) { } } -func cleanupSymbols(scope *table.Scope, st state) []*symbols.Symbol { +func cleanupSymbols(scope *symbols.Scope, st state) []*symbols.Symbol { if scope == nil { return nil } @@ -336,7 +335,7 @@ func cleanupSymbols(scope *table.Scope, st state) []*symbols.Symbol { return cleanup } -func (a *analyzer) cleanupBeforeReturn(scope *table.Scope, stmt *ast.ReturnStmt, st state, loans *loanContext) { +func (a *analyzer) cleanupBeforeReturn(scope *symbols.Scope, stmt *ast.ReturnStmt, st state, loans *loanContext) { if a == nil || stmt == nil { return } @@ -352,7 +351,7 @@ func (a *analyzer) cleanupBeforeReturn(scope *table.Scope, stmt *ast.ReturnStmt, } } -func (a *analyzer) checkScopeDestruction(scope *table.Scope, site ast.Node, loans *loanContext) { +func (a *analyzer) checkScopeDestruction(scope *symbols.Scope, site ast.Node, loans *loanContext) { if a == nil || scope == nil || loans == nil { return } @@ -434,7 +433,7 @@ func symbolIDs(values []*symbols.Symbol) []symbols.SymbolID { return ids } -func (a *analyzer) applyBinding(scope *table.Scope, stmt ast.Stmt, value ast.Expr, st state, loans *loanContext) { +func (a *analyzer) applyBinding(scope *symbols.Scope, stmt ast.Stmt, value ast.Expr, st state, loans *loanContext) { if scope == nil || stmt == nil { return } diff --git a/internal/semantics/ownership/ownership_test.go b/internal/semantics/ownership/ownership_test.go index 8661488..bdc6bb3 100644 --- a/internal/semantics/ownership/ownership_test.go +++ b/internal/semantics/ownership/ownership_test.go @@ -18,7 +18,6 @@ import ( "compiler/internal/semantics/place" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typechecker" "compiler/pkg/peeper" ) @@ -65,8 +64,8 @@ func inspectFunctionAnalysis(t *testing.T, result *ownershipResult, name string) if !ok || fn == nil || fn.Body == nil { t.Fatalf("symbol %q does not have function body", name) } - scope, ok := sym.Scope.(*table.Scope) - if !ok || scope == nil { + scope := sym.Scope + if scope == nil { t.Fatalf("function %q scope missing", name) } cfgFn := result.module.CFG.Function(ir.NodeID(fn.ID())) diff --git a/internal/semantics/ownership/reference.go b/internal/semantics/ownership/reference.go index 651efcb..fd274f2 100644 --- a/internal/semantics/ownership/reference.go +++ b/internal/semantics/ownership/reference.go @@ -12,7 +12,6 @@ import ( "compiler/internal/semantics/consteval" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -132,7 +131,7 @@ func (ctx *loanContext) addTemporary(value []referenceLoan, call ast.Node) { } func (a *analyzer) checkStorageAccess( - scope *table.Scope, + scope *symbols.Scope, expr ast.Expr, st state, loans *loanContext, @@ -327,7 +326,7 @@ func (a *analyzer) referenceHolder(expr ast.Expr) *symbols.Symbol { } } -func (a *analyzer) referenceValueForExpr(scope *table.Scope, expr ast.Expr, st state) ([]referenceLoan, bool) { +func (a *analyzer) referenceValueForExpr(scope *symbols.Scope, expr ast.Expr, st state) ([]referenceLoan, bool) { if a == nil || scope == nil || expr == nil { return []referenceLoan{}, false } @@ -353,7 +352,7 @@ func (a *analyzer) referenceValueForExpr(scope *table.Scope, expr ast.Expr, st s }}, true } -func (a *analyzer) originsForExpr(scope *table.Scope, expr ast.Expr, st state) []place.Origin { +func (a *analyzer) originsForExpr(scope *symbols.Scope, expr ast.Expr, st state) []place.Origin { if a == nil || scope == nil || expr == nil { return nil } @@ -381,7 +380,7 @@ func (a *analyzer) originsForExpr(scope *table.Scope, expr ast.Expr, st state) [ }) } -func (a *analyzer) callReturnOrigins(scope *table.Scope, call *ast.CallExpr, st state) []place.Origin { +func (a *analyzer) callReturnOrigins(scope *symbols.Scope, call *ast.CallExpr, st state) []place.Origin { if a == nil || call == nil || call.Callee == nil { return nil } @@ -396,7 +395,7 @@ func (a *analyzer) callReturnOrigins(scope *table.Scope, call *ast.CallExpr, st return origins } -func (a *analyzer) validateReferenceReturn(scope *table.Scope, stmt *ast.ReturnStmt, st state) { +func (a *analyzer) validateReferenceReturn(scope *symbols.Scope, stmt *ast.ReturnStmt, st state) { if a == nil || a.function == nil || scope == nil || stmt == nil || stmt.Value == nil { return } diff --git a/internal/semantics/place/addressable.go b/internal/semantics/place/addressable.go index b7f77de..79d1cd4 100644 --- a/internal/semantics/place/addressable.go +++ b/internal/semantics/place/addressable.go @@ -3,7 +3,6 @@ package place import ( "compiler/internal/frontend/ast" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -36,7 +35,7 @@ func IsPlaceExpr(expr ast.Expr) bool { return ok && IsPlaceExpr(base) } -func Addressable(scope *table.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) bool { +func Addressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) bool { if scope == nil || expr == nil { return false } @@ -67,7 +66,7 @@ func Addressable(scope *table.Scope, expr ast.Expr, exprType ExprTypeFunc, resol return Addressable(scope, base, exprType, resolve) } -func MutableAddressable(scope *table.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) (mutable bool, sharedReference typeinfo.Type) { +func MutableAddressable(scope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) (mutable bool, sharedReference typeinfo.Type) { if scope == nil || expr == nil { return false, nil } @@ -101,7 +100,7 @@ func MutableAddressable(scope *table.Scope, expr ast.Expr, exprType ExprTypeFunc return MutableAddressable(scope, base, exprType, resolve) } -func LocalRoot(scope, moduleScope *table.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) (*symbols.Symbol, bool) { +func LocalRoot(scope, moduleScope *symbols.Scope, expr ast.Expr, exprType ExprTypeFunc, resolve BindingResolver) (*symbols.Symbol, bool) { if scope == nil || moduleScope == nil || expr == nil { return nil, false } diff --git a/internal/semantics/place/origin.go b/internal/semantics/place/origin.go index 1472fcb..1e5caac 100644 --- a/internal/semantics/place/origin.go +++ b/internal/semantics/place/origin.go @@ -3,7 +3,6 @@ package place import ( "compiler/internal/frontend/ast" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -37,7 +36,7 @@ type OriginOptions struct { // Origins resolves safe-reference dereferences eagerly. Canonical origins never // retain a reference binding as storage identity when its referent is known. -func Origins(scope *table.Scope, expr ast.Expr, opts OriginOptions) []Origin { +func Origins(scope *symbols.Scope, expr ast.Expr, opts OriginOptions) []Origin { if scope == nil || expr == nil { return nil } diff --git a/internal/semantics/place/origin_test.go b/internal/semantics/place/origin_test.go index 771a192..1dcb523 100644 --- a/internal/semantics/place/origin_test.go +++ b/internal/semantics/place/origin_test.go @@ -5,7 +5,6 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -49,7 +48,7 @@ func TestPlaceExpressionProjectionGrammar(t *testing.T) { } func TestPlaceAddressabilityUsesResolvedBindingBeforeScope(t *testing.T) { - scope := table.New(nil) + scope := symbols.NewScope(nil) scopeValue := symbols.New("value", symbols.SymbolVar, &ast.LetDecl{IsMutable: true}, nil) if err := scope.Declare(scopeValue); err != nil { t.Fatal(err) @@ -72,7 +71,7 @@ func TestPlaceAddressabilityUsesResolvedBindingBeforeScope(t *testing.T) { } func TestPlaceAddressabilityPointerAndReferenceBoundaries(t *testing.T) { - scope := table.New(nil) + scope := symbols.NewScope(nil) base := &ast.Ident{Name: "value"} projection := &ast.SelectorExpr{Expr: base, Name: &ast.Ident{Name: "field"}} tests := []struct { @@ -125,8 +124,8 @@ func TestPlaceAddressabilityPointerAndReferenceBoundaries(t *testing.T) { } func TestPlaceLocalRootPreservesBindingLocalAndPointerCutoff(t *testing.T) { - moduleScope := table.New(nil) - scope := table.New(moduleScope) + moduleScope := symbols.NewScope(nil) + scope := symbols.NewScope(moduleScope) local := symbols.New("value", symbols.SymbolVar, &ast.LetDecl{IsMutable: true}, nil) if err := scope.Declare(local); err != nil { t.Fatal(err) @@ -165,7 +164,7 @@ func TestPlaceLocalRootPreservesBindingLocalAndPointerCutoff(t *testing.T) { } func TestOriginsPreferResolvedBindingOverShadowingScope(t *testing.T) { - scope := table.New(nil) + scope := symbols.NewScope(nil) callerValue := symbols.New("value", symbols.SymbolVar, nil, nil) declarationValue := symbols.New("value", symbols.SymbolConst, nil, nil) if err := scope.Declare(callerValue); err != nil { @@ -184,7 +183,7 @@ func TestOriginsPreferResolvedBindingOverShadowingScope(t *testing.T) { } func TestOriginsNormalizeReferenceRootsAndProjections(t *testing.T) { - scope := table.New(nil) + scope := symbols.NewScope(nil) value := symbols.New("value", symbols.SymbolVar, nil, nil) value.BindType(&typeinfo.StructType{Fields: []typeinfo.Field{{Name: "items", Type: &typeinfo.ArrayType{Len: "2", Elem: typeinfo.DefaultIntegerType()}}}}) reference := symbols.New("reference", symbols.SymbolVar, nil, nil) @@ -223,7 +222,7 @@ func TestOriginsNormalizeReferenceRootsAndProjections(t *testing.T) { } func TestOriginsPreserveOwningPointeeAndCollapseUnknownDescendants(t *testing.T) { - scope := table.New(nil) + scope := symbols.NewScope(nil) owner := symbols.New("owner", symbols.SymbolVar, nil, nil) inner := &typeinfo.ArrayType{Len: "2", Elem: typeinfo.DefaultIntegerType()} owner.BindType(&typeinfo.OwnedPtrType{Target: &typeinfo.ArrayType{Len: "2", Elem: inner}}) diff --git a/internal/semantics/resolver/resolver.go b/internal/semantics/resolver/resolver.go index 7b04da8..fffb0f8 100644 --- a/internal/semantics/resolver/resolver.go +++ b/internal/semantics/resolver/resolver.go @@ -8,7 +8,6 @@ import ( "compiler/internal/problems" "compiler/internal/project" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/source" ) @@ -85,7 +84,7 @@ func (r *resolver) resolveFunction(fn *ast.FnDecl) { if sym == nil || sym.Scope == nil { return } - funcScope := sym.Scope.(*table.Scope) + funcScope := sym.Scope params := fn.ParamsWithReceiver() for i, param := range params { if param.Name == nil || param.Name.Name == "" { @@ -129,7 +128,7 @@ func (r *resolver) resolveFunction(fn *ast.FnDecl) { } } -func (r *resolver) resolveBlock(scope *table.Scope, block *ast.BlockStmt) { +func (r *resolver) resolveBlock(scope *symbols.Scope, block *ast.BlockStmt) { if block == nil { return } @@ -139,13 +138,13 @@ func (r *resolver) resolveBlock(scope *table.Scope, block *ast.BlockStmt) { } } -func (r *resolver) resolveStmt(scope *table.Scope, stmt ast.Stmt) { +func (r *resolver) resolveStmt(scope *symbols.Scope, stmt ast.Stmt) { if stmt == nil { return } switch node := stmt.(type) { case *ast.BlockStmt: - r.resolveBlock(table.New(scope), node) + r.resolveBlock(symbols.NewScope(scope), node) case *ast.LetDecl: r.resolveLocalBinding(scope, node.Name, symbols.SymbolVar, node.Value, node, node.Location) case *ast.ConstDecl: @@ -160,9 +159,9 @@ func (r *resolver) resolveStmt(scope *table.Scope, stmt ast.Stmt) { return } r.resolveExpr(scope, node.Cond) - r.resolveBlock(table.New(scope), node.Then) + r.resolveBlock(symbols.NewScope(scope), node.Then) if elseBlock, ok := node.Else.(*ast.BlockStmt); ok { - r.resolveBlock(table.New(scope), elseBlock) + r.resolveBlock(symbols.NewScope(scope), elseBlock) return } if node.Else != nil { @@ -172,7 +171,7 @@ func (r *resolver) resolveStmt(scope *table.Scope, stmt ast.Stmt) { if node.Cond != nil { r.resolveExpr(scope, node.Cond) } - r.resolveBlock(table.New(scope), node.Body) + r.resolveBlock(symbols.NewScope(scope), node.Body) case *ast.ExprStmt: r.resolveExpr(scope, node.Expr) case *ast.AssignStmt: @@ -186,7 +185,7 @@ func (r *resolver) resolveStmt(scope *table.Scope, stmt ast.Stmt) { } } -func (r *resolver) resolveLocalBinding(scope *table.Scope, name *ast.Ident, kind symbols.Kind, value ast.Expr, node ast.Node, loc *source.Location) { +func (r *resolver) resolveLocalBinding(scope *symbols.Scope, name *ast.Ident, kind symbols.Kind, value ast.Expr, node ast.Node, loc *source.Location) { sym := symbols.New(name.Name, kind, node, ast.LocOf(name)) sym.Initializing = true if err := scope.Declare(sym); err != nil { @@ -199,7 +198,7 @@ func (r *resolver) resolveLocalBinding(scope *table.Scope, name *ast.Ident, kind sym.Initializing = false } -func (r *resolver) resolveExpr(scope *table.Scope, expr ast.Expr) { +func (r *resolver) resolveExpr(scope *symbols.Scope, expr ast.Expr) { if expr == nil { return } @@ -297,7 +296,7 @@ func Resolve(ctx *project.CompilerContext, module *project.Module) { r.resolveModule() } -func (r *resolver) resolveAssignTarget(scope *table.Scope, expr ast.Expr) { +func (r *resolver) resolveAssignTarget(scope *symbols.Scope, expr ast.Expr) { switch node := expr.(type) { case *ast.Ident: sym, ok := scope.Lookup(node.Name) diff --git a/internal/semantics/resolver/suggest.go b/internal/semantics/resolver/suggest.go index b0e2255..e86aee4 100644 --- a/internal/semantics/resolver/suggest.go +++ b/internal/semantics/resolver/suggest.go @@ -4,11 +4,11 @@ import ( "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/project" - "compiler/internal/semantics/table" + "compiler/internal/semantics/symbols" "compiler/pkg/colors" ) -func reportUnresolved(module *project.Module, scope *table.Scope, node *ast.Ident, diag *diagnostics.DiagnosticBag) bool { +func reportUnresolved(module *project.Module, scope *symbols.Scope, node *ast.Ident, diag *diagnostics.DiagnosticBag) bool { if module == nil || node == nil || diag == nil { return false } @@ -26,7 +26,7 @@ func reportUnresolved(module *project.Module, scope *table.Scope, node *ast.Iden return false } -func nearestSymbolName(name string, scope *table.Scope) (string, bool) { +func nearestSymbolName(name string, scope *symbols.Scope) (string, bool) { candidates := make([]diagnostics.NameCandidate, 0) seen := make(map[string]struct{}) scopeDepth := 0 diff --git a/internal/semantics/table/scope.go b/internal/semantics/symbols/scope.go similarity index 62% rename from internal/semantics/table/scope.go rename to internal/semantics/symbols/scope.go index e588926..1aa1cd0 100644 --- a/internal/semantics/table/scope.go +++ b/internal/semantics/symbols/scope.go @@ -1,25 +1,25 @@ -package table +package symbols import ( - "compiler/internal/frontend/ast" - "compiler/internal/semantics/symbols" "errors" "fmt" + + "compiler/internal/frontend/ast" ) type Scope struct { parent *Scope - byName map[string]symbols.SymbolID - byID map[symbols.SymbolID]*symbols.Symbol - order []symbols.SymbolID + byName map[string]SymbolID + byID map[SymbolID]*Symbol + order []SymbolID } -func New(parent *Scope) *Scope { +func NewScope(parent *Scope) *Scope { return &Scope{ parent: parent, - byName: make(map[string]symbols.SymbolID), - byID: make(map[symbols.SymbolID]*symbols.Symbol), - order: make([]symbols.SymbolID, 0), + byName: make(map[string]SymbolID), + byID: make(map[SymbolID]*Symbol), + order: make([]SymbolID, 0), } } @@ -30,7 +30,7 @@ func (s *Scope) Parent() *Scope { return s.parent } -func (s *Scope) Declare(sym *symbols.Symbol) error { +func (s *Scope) Declare(sym *Symbol) error { if s == nil || sym == nil { return errors.New("invalid symbol or scope") } @@ -45,7 +45,7 @@ func (s *Scope) Declare(sym *symbols.Symbol) error { return nil } -func (s *Scope) LookupLocal(name string) (*symbols.Symbol, bool) { +func (s *Scope) LookupLocal(name string) (*Symbol, bool) { if s == nil { return nil, false } @@ -57,7 +57,7 @@ func (s *Scope) LookupLocal(name string) (*symbols.Symbol, bool) { return sym, sym != nil } -func (s *Scope) Lookup(name string) (*symbols.Symbol, bool) { +func (s *Scope) Lookup(name string) (*Symbol, bool) { for scope := s; scope != nil; scope = scope.parent { if id, ok := scope.byName[name]; ok { sym := scope.byID[id] @@ -70,7 +70,7 @@ func (s *Scope) Lookup(name string) (*symbols.Symbol, bool) { return nil, false } -func (s *Scope) LookupNode(node ast.Node) (*symbols.Symbol, bool) { +func (s *Scope) LookupNode(node ast.Node) (*Symbol, bool) { if s == nil || node == nil { return nil, false } @@ -83,11 +83,11 @@ func (s *Scope) LookupNode(node ast.Node) (*symbols.Symbol, bool) { return nil, false } -func (s *Scope) Symbols() []*symbols.Symbol { +func (s *Scope) Symbols() []*Symbol { if s == nil { return nil } - out := make([]*symbols.Symbol, 0, len(s.order)) + out := make([]*Symbol, 0, len(s.order)) for _, id := range s.order { if sym := s.byID[id]; sym != nil { out = append(out, sym) @@ -98,5 +98,5 @@ func (s *Scope) Symbols() []*symbols.Symbol { func (s *Scope) IsMutableBinding(name string) bool { sym, found := s.Lookup(name) - return found && sym != nil && (sym.Kind == symbols.SymbolVar || sym.Kind == symbols.SymbolParam) && sym.IsMutable() + return found && sym != nil && (sym.Kind == SymbolVar || sym.Kind == SymbolParam) && sym.IsMutable() } diff --git a/internal/semantics/table/scope_test.go b/internal/semantics/symbols/scope_test.go similarity index 78% rename from internal/semantics/table/scope_test.go rename to internal/semantics/symbols/scope_test.go index 412a79e..08130ff 100644 --- a/internal/semantics/table/scope_test.go +++ b/internal/semantics/symbols/scope_test.go @@ -1,15 +1,14 @@ -package table +package symbols import ( "testing" "compiler/internal/frontend/ast" - "compiler/internal/semantics/symbols" ) func TestScopeDeclareAndLookup(t *testing.T) { - global := New(nil) - sx := symbols.New("x", symbols.SymbolVar, nil, ast.LocOf(nil)) + global := NewScope(nil) + sx := New("x", SymbolVar, nil, ast.LocOf(nil)) if err := global.Declare(sx); err != nil { t.Fatalf("declare x failed: %v", err) } @@ -20,7 +19,7 @@ func TestScopeDeclareAndLookup(t *testing.T) { t.Fatalf("lookup local x failed") } - child := New(global) + child := NewScope(global) if got, ok := child.Lookup("x"); !ok || got != sx { t.Fatalf("child should resolve parent symbol") } @@ -30,9 +29,9 @@ func TestScopeDeclareAndLookup(t *testing.T) { } func TestScopeSymbolsOrder(t *testing.T) { - s := New(nil) - a := symbols.New("a", symbols.SymbolVar, nil, ast.LocOf(nil)) - b := symbols.New("b", symbols.SymbolVar, nil, ast.LocOf(nil)) + s := NewScope(nil) + a := New("a", SymbolVar, nil, ast.LocOf(nil)) + b := New("b", SymbolVar, nil, ast.LocOf(nil)) if err := s.Declare(a); err != nil { t.Fatalf("declare a failed: %v", err) } @@ -46,11 +45,11 @@ func TestScopeSymbolsOrder(t *testing.T) { } func TestScopeAllowsMultipleDiscardDeclarations(t *testing.T) { - s := New(nil) + s := NewScope(nil) firstNode := &ast.LetDecl{} secondNode := &ast.LetDecl{} - first := symbols.New("_", symbols.SymbolVar, firstNode, ast.LocOf(nil)) - second := symbols.New("_", symbols.SymbolVar, secondNode, ast.LocOf(nil)) + first := New("_", SymbolVar, firstNode, ast.LocOf(nil)) + second := New("_", SymbolVar, secondNode, ast.LocOf(nil)) if err := s.Declare(first); err != nil { t.Fatalf("declare first discard failed: %v", err) } @@ -70,8 +69,8 @@ func TestScopeAllowsMultipleDiscardDeclarations(t *testing.T) { } func TestScopeMutableBindingIncludesParameters(t *testing.T) { - s := New(nil) - param := symbols.New("value", symbols.SymbolParam, nil, ast.LocOf(nil)) + s := NewScope(nil) + param := New("value", SymbolParam, nil, ast.LocOf(nil)) param.Mutable = true if err := s.Declare(param); err != nil { t.Fatalf("declare mutable param failed: %v", err) diff --git a/internal/semantics/symbols/symbol.go b/internal/semantics/symbols/symbol.go index 7111bd6..3e01364 100644 --- a/internal/semantics/symbols/symbol.go +++ b/internal/semantics/symbols/symbol.go @@ -69,7 +69,7 @@ type Symbol struct { DefiningModule DefiningModuleKey Location *source.Location ASTNode ast.Node - Scope any // Pointer to table.Scope (only if Kind == SymbolFunc) + Scope *Scope } func New(name string, kind Kind, node ast.Node, location *source.Location) *Symbol { diff --git a/internal/semantics/symbols/symbol_test.go b/internal/semantics/symbols/symbol_test.go index 25c7d3b..0c2803e 100644 --- a/internal/semantics/symbols/symbol_test.go +++ b/internal/semantics/symbols/symbol_test.go @@ -17,3 +17,11 @@ func TestNewHandlesTypedNilNode(t *testing.T) { t.Fatalf("expected nil location for typed nil node, got %#v", sym.Location) } } + +func TestFunctionScopeHasConcreteType(t *testing.T) { + sym := New("main", SymbolFunc, nil, nil) + var scope *Scope = sym.Scope + if scope != nil { + t.Fatalf("new function scope = %v, want nil", scope) + } +} diff --git a/internal/semantics/typechecker/assignability.go b/internal/semantics/typechecker/assignability.go index a460a7a..51a6227 100644 --- a/internal/semantics/typechecker/assignability.go +++ b/internal/semantics/typechecker/assignability.go @@ -9,7 +9,6 @@ import ( "compiler/internal/project" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -234,7 +233,7 @@ func (c *checker) boundInterfaceMethodType(method typeinfo.Method, receiverType return fnType } -func (c *checker) mutableAddressableExpr(scope *table.Scope, expr ast.Expr) (bool, typeinfo.Type) { +func (c *checker) mutableAddressableExpr(scope *symbols.Scope, expr ast.Expr) (bool, typeinfo.Type) { if c == nil { return false, nil } @@ -243,7 +242,7 @@ func (c *checker) mutableAddressableExpr(scope *table.Scope, expr ast.Expr) (boo }, c.expandedDefaultBinding) } -func (c *checker) mutableImplicitArgumentDiagnostic(scope *table.Scope, expr ast.Expr) (ast.Node, string, bool) { +func (c *checker) mutableImplicitArgumentDiagnostic(scope *symbols.Scope, expr ast.Expr) (ast.Node, string, bool) { if c == nil || scope == nil || expr == nil { return nil, "", false } diff --git a/internal/semantics/typechecker/check_call.go b/internal/semantics/typechecker/check_call.go index 3ed815f..1389b41 100644 --- a/internal/semantics/typechecker/check_call.go +++ b/internal/semantics/typechecker/check_call.go @@ -10,11 +10,10 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) -func (c *checker) typeFreeExpr(scope *table.Scope, node *ast.FreeExpr) typeinfo.Type { +func (c *checker) typeFreeExpr(scope *symbols.Scope, node *ast.FreeExpr) typeinfo.Type { if node == nil || node.Expr == nil { return &typeinfo.InvalidType{} } @@ -30,7 +29,7 @@ func (c *checker) typeFreeExpr(scope *table.Scope, node *ast.FreeExpr) typeinfo. return nil } -func (c *checker) typePrintExpr(scope *table.Scope, node *ast.PrintExpr) typeinfo.Type { +func (c *checker) typePrintExpr(scope *symbols.Scope, node *ast.PrintExpr) typeinfo.Type { if node == nil || node.Expr == nil { return &typeinfo.InvalidType{} } @@ -55,7 +54,7 @@ func (c *checker) typePrintExpr(scope *table.Scope, node *ast.PrintExpr) typeinf } } -func (c *checker) typeCallExpr(scope *table.Scope, node *ast.CallExpr, expected typeinfo.Type) typeinfo.Type { +func (c *checker) typeCallExpr(scope *symbols.Scope, node *ast.CallExpr, expected typeinfo.Type) typeinfo.Type { if selector, ok := node.Callee.(*ast.SelectorExpr); ok && selector != nil { return c.typeSelectorCall(scope, selector, node) } @@ -95,7 +94,7 @@ func (c *checker) typeCallExpr(scope *table.Scope, node *ast.CallExpr, expected return c.callReturnType(node, calleeType) } -func (c *checker) typeCollectionCall(scope *table.Scope, node *ast.CallExpr, definition intrinsics.FunctionDefinition) typeinfo.Type { +func (c *checker) typeCollectionCall(scope *symbols.Scope, node *ast.CallExpr, definition intrinsics.FunctionDefinition) typeinfo.Type { if len(node.Args) != 1 { for _, arg := range node.Args { c.typeExpr(scope, arg, nil) @@ -122,7 +121,7 @@ func (c *checker) typeCollectionCall(scope *table.Scope, node *ast.CallExpr, def return c.callReturnType(node, fnType) } -func (c *checker) typeDynamicArrayOwnerCall(scope *table.Scope, node *ast.CallExpr, definition intrinsics.FunctionDefinition) typeinfo.Type { +func (c *checker) typeDynamicArrayOwnerCall(scope *symbols.Scope, node *ast.CallExpr, definition intrinsics.FunctionDefinition) typeinfo.Type { op := definition.Operation genericSignature := definition.Signature(nil, c.ctx.Target) if genericSignature == nil { @@ -177,7 +176,7 @@ func (c *checker) typeDynamicArrayOwnerCall(scope *table.Scope, node *ast.CallEx return nil } -func (c *checker) typeAllocCall(scope *table.Scope, node *ast.CallExpr) typeinfo.Type { +func (c *checker) typeAllocCall(scope *symbols.Scope, node *ast.CallExpr) typeinfo.Type { const minArgs, maxArgs = 1, 2 argCount := len(node.Args) if argCount < minArgs || argCount > maxArgs { @@ -226,7 +225,7 @@ func (c *checker) typeAllocCall(scope *table.Scope, node *ast.CallExpr) typeinfo return &typeinfo.OwnedPtrType{Target: valueType} } -func (c *checker) typeSelectorCall(scope *table.Scope, selector *ast.SelectorExpr, call *ast.CallExpr) typeinfo.Type { +func (c *checker) typeSelectorCall(scope *symbols.Scope, selector *ast.SelectorExpr, call *ast.CallExpr) typeinfo.Type { baseType := c.typeExpr(scope, selector.Expr, nil) if baseType == nil || typeinfo.IsInvalidOrUnknown(baseType) { return &typeinfo.InvalidType{} @@ -275,7 +274,7 @@ func (c *checker) typeSelectorCall(scope *table.Scope, selector *ast.SelectorExp return &typeinfo.InvalidType{} } -func (c *checker) checkCall(scope *table.Scope, receiverExpr ast.Expr, callExpr *ast.CallExpr, calleeType typeinfo.Type, args []typeinfo.Type) { +func (c *checker) checkCall(scope *symbols.Scope, receiverExpr ast.Expr, callExpr *ast.CallExpr, calleeType typeinfo.Type, args []typeinfo.Type) { if c == nil || callExpr == nil || calleeType == nil { return } @@ -356,7 +355,7 @@ func (c *checker) checkCall(scope *table.Scope, receiverExpr ast.Expr, callExpr // acceptImplicitCallArgument is the single semantic gate for method receivers // and piped argument zero. Ordinary call arguments remain explicit. -func (c *checker) acceptImplicitCallArgument(scope *table.Scope, expr ast.Expr, argType, paramType typeinfo.Type) bool { +func (c *checker) acceptImplicitCallArgument(scope *symbols.Scope, expr ast.Expr, argType, paramType typeinfo.Type) bool { refTarget, mutable, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(paramType)) if !reference || !c.matchesImplicitCallTarget(refTarget, argType) { return false diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index b1b9fa0..a2eeefb 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -14,14 +14,13 @@ import ( "compiler/internal/semantics/consteval" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/pkg/numeric" ) // typeExpr computes the type of an expression using scope lookup, records it in the // module's ExprTypes side table for downstream phases, and returns it. -func (c *checker) typeExpr(scope *table.Scope, expr ast.Expr, expected typeinfo.Type) (resolved typeinfo.Type) { +func (c *checker) typeExpr(scope *symbols.Scope, expr ast.Expr, expected typeinfo.Type) (resolved typeinfo.Type) { if expr == nil { return nil } @@ -134,7 +133,7 @@ func (c *checker) typeExpr(scope *table.Scope, expr ast.Expr, expected typeinfo. } } -func (c *checker) typeUnaryExpr(scope *table.Scope, node *ast.UnaryExpr, expected typeinfo.Type) typeinfo.Type { +func (c *checker) typeUnaryExpr(scope *symbols.Scope, node *ast.UnaryExpr, expected typeinfo.Type) typeinfo.Type { if node.Op != "+" && node.Op != "-" && node.Op != "!" && node.Op != "~" { c.ctx.Diagnostics.Add(invalidOperationError(node, "unsupported unary operator `"+node.Op+"`")) @@ -182,7 +181,7 @@ func (c *checker) typeUnaryExpr(scope *table.Scope, node *ast.UnaryExpr, expecte return argType } -func (c *checker) typeAddressExpr(scope *table.Scope, node *ast.AddressExpr, expected typeinfo.Type) typeinfo.Type { +func (c *checker) typeAddressExpr(scope *symbols.Scope, node *ast.AddressExpr, expected typeinfo.Type) typeinfo.Type { if node == nil || node.Expr == nil { return &typeinfo.InvalidType{} } @@ -227,7 +226,7 @@ func (c *checker) typeAddressExpr(scope *table.Scope, node *ast.AddressExpr, exp return &typeinfo.RawPtrType{} } -func (c *checker) typeBinaryExpr(scope *table.Scope, node *ast.BinaryExpr, expected typeinfo.Type) typeinfo.Type { +func (c *checker) typeBinaryExpr(scope *symbols.Scope, node *ast.BinaryExpr, expected typeinfo.Type) typeinfo.Type { operandExpected := expected if binaryResultIsBool(node.Op) { operandExpected = nil @@ -379,7 +378,7 @@ func isOptionalType(typ typeinfo.Type) bool { return ok } -func (c *checker) typeSelectorExpr(scope *table.Scope, node *ast.SelectorExpr) typeinfo.Type { +func (c *checker) typeSelectorExpr(scope *symbols.Scope, node *ast.SelectorExpr) typeinfo.Type { if node == nil || node.Expr == nil || node.Name == nil { return &typeinfo.InvalidType{} } @@ -406,7 +405,7 @@ func (c *checker) typeSelectorExpr(scope *table.Scope, node *ast.SelectorExpr) t return &typeinfo.InvalidType{} } -func (c *checker) typeIndexExpr(scope *table.Scope, node *ast.IndexExpr) typeinfo.Type { +func (c *checker) typeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr) typeinfo.Type { if node == nil || node.Expr == nil || node.Index == nil { return &typeinfo.InvalidType{} } @@ -459,7 +458,7 @@ func (c *checker) typeIndexExpr(scope *table.Scope, node *ast.IndexExpr) typeinf return elem } -func (c *checker) typeRangeIndexExpr(scope *table.Scope, node *ast.IndexExpr, rangeIndex *ast.RangeExpr, baseType typeinfo.Type) typeinfo.Type { +func (c *checker) typeRangeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr, rangeIndex *ast.RangeExpr, baseType typeinfo.Type) typeinfo.Type { if c == nil || node == nil || rangeIndex == nil { return &typeinfo.InvalidType{} } @@ -549,7 +548,7 @@ func indexableSequence(t typeinfo.Type) (typeinfo.Type, indexableSequenceShape, return nil, 0, false } -func (c *checker) checkRangeBound(scope *table.Scope, expr ast.Expr) { +func (c *checker) checkRangeBound(scope *symbols.Scope, expr ast.Expr) { if c == nil || expr == nil { return } @@ -564,7 +563,7 @@ func (c *checker) checkRangeBound(scope *table.Scope, expr ast.Expr) { } } -func (c *checker) typeStructLit(scope *table.Scope, node *ast.StructLit, expected typeinfo.Type) typeinfo.Type { +func (c *checker) typeStructLit(scope *symbols.Scope, node *ast.StructLit, expected typeinfo.Type) typeinfo.Type { if node == nil { return &typeinfo.InvalidType{} } @@ -595,7 +594,7 @@ func (c *checker) expectedStructType(expected typeinfo.Type) (*typeinfo.StructTy return nil, nil } -func (c *checker) typeStructLitWithExpected(scope *table.Scope, node *ast.StructLit, targetStruct *typeinfo.StructType, targetType typeinfo.Type) typeinfo.Type { +func (c *checker) typeStructLitWithExpected(scope *symbols.Scope, node *ast.StructLit, targetStruct *typeinfo.StructType, targetType typeinfo.Type) typeinfo.Type { if targetStruct == nil { return &typeinfo.InvalidType{} } @@ -638,7 +637,7 @@ func (c *checker) typeStructLitWithExpected(scope *table.Scope, node *ast.Struct return targetType } -func (c *checker) typeStructLitAnonymous(scope *table.Scope, node *ast.StructLit) typeinfo.Type { +func (c *checker) typeStructLitAnonymous(scope *symbols.Scope, node *ast.StructLit) typeinfo.Type { fields := make([]typeinfo.Field, 0, len(node.Fields)) seen := make(map[string]struct{}, len(node.Fields)) for _, field := range node.Fields { @@ -661,7 +660,7 @@ func (c *checker) typeStructLitAnonymous(scope *table.Scope, node *ast.StructLit return &typeinfo.StructType{Fields: fields} } -func (c *checker) typeArrayLit(scope *table.Scope, node *ast.ArrayLit) typeinfo.Type { +func (c *checker) typeArrayLit(scope *symbols.Scope, node *ast.ArrayLit) typeinfo.Type { if node == nil { return &typeinfo.InvalidType{} } @@ -706,7 +705,7 @@ func (c *checker) typeArrayLit(scope *table.Scope, node *ast.ArrayLit) typeinfo. return arrayType } -func (c *checker) typeAsExpr(scope *table.Scope, node *ast.AsExpr) typeinfo.Type { +func (c *checker) typeAsExpr(scope *symbols.Scope, node *ast.AsExpr) typeinfo.Type { if c == nil || node == nil { return nil } diff --git a/internal/semantics/typechecker/check_fn.go b/internal/semantics/typechecker/check_fn.go index 66da093..33c9814 100644 --- a/internal/semantics/typechecker/check_fn.go +++ b/internal/semantics/typechecker/check_fn.go @@ -7,7 +7,6 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/project" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -19,7 +18,7 @@ func (c *checker) checkFunction(sym *symbols.Symbol, fn *ast.FnDecl) { if sym.Scope == nil { return } - funcScope := sym.Scope.(*table.Scope) + funcScope := sym.Scope for _, param := range fn.ParamsWithReceiver() { if param.Name == nil { continue @@ -39,7 +38,7 @@ func (c *checker) checkFunction(sym *symbols.Symbol, fn *ast.FnDecl) { c.checkBlock(funcScope, fn.Body, returnType) } -func (c *checker) checkDefaultParameters(scope *table.Scope, fn *ast.FnDecl) { +func (c *checker) checkDefaultParameters(scope *symbols.Scope, fn *ast.FnDecl) { if c == nil || scope == nil || fn == nil { return } @@ -74,7 +73,7 @@ func (c *checker) checkDefaultParameters(scope *table.Scope, fn *ast.FnDecl) { } } -func (c *checker) rejectOwnedParameterReferences(scope *table.Scope, fn *ast.FnDecl, current int, expr ast.Expr) { +func (c *checker) rejectOwnedParameterReferences(scope *symbols.Scope, fn *ast.FnDecl, current int, expr ast.Expr) { if c == nil || c.module == nil || c.module.Semantics == nil || fn == nil || expr == nil { return } diff --git a/internal/semantics/typechecker/check_stmt.go b/internal/semantics/typechecker/check_stmt.go index 41dcffe..3bc09e6 100644 --- a/internal/semantics/typechecker/check_stmt.go +++ b/internal/semantics/typechecker/check_stmt.go @@ -8,11 +8,10 @@ import ( "compiler/internal/project" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) -func (c *checker) checkBlock(parentScope *table.Scope, block *ast.BlockStmt, returnType typeinfo.Type) { +func (c *checker) checkBlock(parentScope *symbols.Scope, block *ast.BlockStmt, returnType typeinfo.Type) { if block == nil { return } @@ -27,7 +26,7 @@ func (c *checker) checkBlock(parentScope *table.Scope, block *ast.BlockStmt, ret } } -func (c *checker) checkStmt(scope *table.Scope, stmt ast.Stmt, returnType typeinfo.Type) { +func (c *checker) checkStmt(scope *symbols.Scope, stmt ast.Stmt, returnType typeinfo.Type) { if stmt == nil { return } @@ -102,7 +101,7 @@ func (c *checker) checkStmt(scope *table.Scope, stmt ast.Stmt, returnType typein } } -func (c *checker) checkAssign(scope *table.Scope, node *ast.AssignStmt) { +func (c *checker) checkAssign(scope *symbols.Scope, node *ast.AssignStmt) { if c == nil || scope == nil || node == nil || node.Target == nil || node.Value == nil { return } @@ -191,7 +190,7 @@ func (c *checker) checkAssign(scope *table.Scope, node *ast.AssignStmt) { } } -func (c *checker) checkIndexAssignmentTarget(scope *table.Scope, target *ast.IndexExpr, targetType typeinfo.Type) bool { +func (c *checker) checkIndexAssignmentTarget(scope *symbols.Scope, target *ast.IndexExpr, targetType typeinfo.Type) bool { if c == nil || target == nil || target.Expr == nil { return false } @@ -224,7 +223,7 @@ func (c *checker) checkIndexAssignmentTarget(scope *table.Scope, target *ast.Ind return false } -func (c *checker) checkBinding(scope *table.Scope, node ast.Stmt, requireInitializer bool) { +func (c *checker) checkBinding(scope *symbols.Scope, node ast.Stmt, requireInitializer bool) { if c == nil || node == nil { return } @@ -340,7 +339,7 @@ func (c *checker) rejectUnsizedType(typ typeinfo.Type, site ast.Node, context st return true } -func (c *checker) rejectBindingReferenceStorage(scope *table.Scope, typ typeinfo.Type, site ast.Node) bool { +func (c *checker) rejectBindingReferenceStorage(scope *symbols.Scope, typ typeinfo.Type, site ast.Node) bool { moduleBinding := c != nil && c.module != nil && scope == c.module.ModuleScope context := "array or heap-owned values" if moduleBinding { @@ -362,7 +361,7 @@ func (c *checker) rejectReferenceStorage(typ typeinfo.Type, site ast.Node, conte return true } -func (c *checker) rejectTemporaryBorrowEscape(scope *table.Scope, expr ast.Expr, context string) bool { +func (c *checker) rejectTemporaryBorrowEscape(scope *symbols.Scope, expr ast.Expr, context string) bool { if c.module == nil || c.module.Semantics == nil { return false } @@ -379,7 +378,7 @@ func (c *checker) rejectTemporaryBorrowEscape(scope *table.Scope, expr ast.Expr, return true } -func (c *checker) temporaryBorrowSource(scope *table.Scope, expr ast.Expr) ast.Expr { +func (c *checker) temporaryBorrowSource(scope *symbols.Scope, expr ast.Expr) ast.Expr { if c == nil || c.module == nil || c.module.Semantics == nil || expr == nil { return nil } diff --git a/internal/semantics/typechecker/typechecker.go b/internal/semantics/typechecker/typechecker.go index 02688aa..9d097eb 100644 --- a/internal/semantics/typechecker/typechecker.go +++ b/internal/semantics/typechecker/typechecker.go @@ -5,7 +5,6 @@ import ( "compiler/internal/project" "compiler/internal/semantics/place" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" ) @@ -20,7 +19,7 @@ const allowImplicitInterfaceConversion = true // enclosingFnDecl walks up the scope chain and returns the FnDecl of the // enclosing function, or nil if not inside a function body. -func (c *checker) enclosingFnDecl(scope *table.Scope) *ast.FnDecl { +func (c *checker) enclosingFnDecl(scope *symbols.Scope) *ast.FnDecl { if c == nil || c.module == nil || c.module.ModuleScope == nil { return nil } diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 6a6f7db..55ee3a7 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -15,7 +15,6 @@ import ( "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/resolver" "compiler/internal/semantics/symbols" - "compiler/internal/semantics/table" "compiler/internal/semantics/typeinfo" "compiler/internal/target" "compiler/pkg/peeper" @@ -832,7 +831,7 @@ fn main() -> i32 { if !ok || sym == nil || sym.Scope == nil { t.Fatalf("expected main function scope") } - funcScope := sym.Scope.(*table.Scope) + funcScope := sym.Scope myval, ok := funcScope.LookupLocal("myval") if !ok || myval == nil { t.Fatalf("expected myval local symbol") @@ -861,7 +860,7 @@ fn main() -> i32 { if !ok || sym == nil || sym.Scope == nil { t.Fatalf("expected main function scope") } - funcScope := sym.Scope.(*table.Scope) + funcScope := sym.Scope myval, ok := funcScope.LookupLocal("myval") if !ok || myval == nil { t.Fatalf("expected myval local symbol") From 4bda35ed22b7c2578317c227f851430189b3487d Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 15:58:00 +0600 Subject: [PATCH 08/12] Remove pipeline wrapper Expose pipeline.Run with explicit compiler context and convert receiver helpers to direct functions while preserving phase scheduling and validation behavior. --- internal/driver/compiler.go | 4 +- internal/pipeline/pipeline.go | 126 +++++++++++++---------------- internal/pipeline/pipeline_test.go | 68 +++++++--------- 3 files changed, 91 insertions(+), 107 deletions(-) diff --git a/internal/driver/compiler.go b/internal/driver/compiler.go index 1bc2794..56d4bdc 100644 --- a/internal/driver/compiler.go +++ b/internal/driver/compiler.go @@ -50,7 +50,7 @@ func CompileFile(ctx *project.CompilerContext, path string, overlay *string) *pr } if module, ok := prelude.ModuleForFile(ctx, absPath, content); ok { module.IsEntry = true - if err := pipeline.New(ctx).Run(module); err != nil { + if err := pipeline.Run(ctx, module); err != nil { loadDiag.Add(diagnostics.NewError("pipeline run: " + err.Error())) return nil } @@ -60,7 +60,7 @@ func CompileFile(ctx *project.CompilerContext, path string, overlay *string) *pr if module != nil { module.IsEntry = true } - if err := pipeline.New(ctx).Run(module); err != nil { + if err := pipeline.Run(ctx, module); err != nil { loadDiag.Add(diagnostics.NewError("pipeline run: " + err.Error())) return nil } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 460090b..c9b707a 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -30,35 +30,25 @@ import ( "compiler/internal/semantics/usage" ) -// Ordered phase execution for one compiler project. -type Pipeline struct { - ctx *project.CompilerContext -} - -// Bind a pipeline to shared compiler state. -func New(ctx *project.CompilerContext) *Pipeline { - return &Pipeline{ctx: ctx} -} - // Run the central lex -> parse -> analyze -> HIR -> MIR -> LLVM flow. -func (p *Pipeline) Run(entry *project.Module) error { - if p == nil || p.ctx == nil || entry == nil { +func Run(ctx *project.CompilerContext, entry *project.Module) error { + if ctx == nil || entry == nil { return errors.New("empty pipeline") } entry.IsEntry = true - p.ctx.AddModule(entry) - p.ctx.CompletedProjectPhase = phase.Load - diag := p.ctx.Diagnostics + ctx.AddModule(entry) + ctx.CompletedProjectPhase = phase.Load + diag := ctx.Diagnostics loadDiag := diag.BeginPhase(phase.Load, "") finalDiag := diag.BeginPhase(phase.Finalize, "") loader := &moduleLoader{ - ctx: p.ctx, + ctx: ctx, scheduled: make(map[string]struct{}), } preludeKey := "" - if preludeMod, ok := p.ctx.ModuleByKey("core:prelude/global"); ok { + if preludeMod, ok := ctx.ModuleByKey("core:prelude/global"); ok { if err := loader.Load(preludeMod); err != nil { return err } @@ -71,16 +61,16 @@ func (p *Pipeline) Run(entry *project.Module) error { // Ensure topo-sort puts prelude first by making all non-prelude modules // depend on it. This removes the need for any special-case ordering logic. if preludeKey != "" { - for _, mod := range p.ctx.Modules() { + for _, mod := range ctx.Modules() { if mod != nil && mod.Key != preludeKey { - if p.ctx.Graph != nil { - p.ctx.Graph.AddEdge(graph.NodeID(mod.Key), graph.NodeID(preludeKey)) + if ctx.Graph != nil { + ctx.Graph.AddEdge(graph.NodeID(mod.Key), graph.NodeID(preludeKey)) } } } } - modules := p.ctx.Modules() + modules := ctx.Modules() moduleIndex := make(map[graph.NodeID]*project.Module, len(modules)) moduleIDs := make([]graph.NodeID, 0, len(modules)) for _, mod := range modules { @@ -96,8 +86,8 @@ func (p *Pipeline) Run(entry *project.Module) error { orderedIDs []graph.NodeID cycles [][]graph.NodeID ) - if p.ctx.Graph != nil { - orderedIDs, cycles = p.ctx.Graph.TopoSort(moduleIDs) + if ctx.Graph != nil { + orderedIDs, cycles = ctx.Graph.TopoSort(moduleIDs) } if len(cycles) > 0 { for _, cycle := range cycles { @@ -127,8 +117,8 @@ func (p *Pipeline) Run(entry *project.Module) error { if preludeKey != "" { prelude = moduleIndex[graph.NodeID(preludeKey)] } - preludeInjected := p.advanceModulesThrough(orderedModules, prelude, prelude == nil, phase.Ownership, diag) - p.ctx.CompletedProjectPhase = phase.Ownership + preludeInjected := advanceModulesThrough(ctx, orderedModules, prelude, prelude == nil, phase.Ownership, diag) + ctx.CompletedProjectPhase = phase.Ownership if diag != nil && diag.HasErrors() { return nil } @@ -140,21 +130,21 @@ func (p *Pipeline) Run(entry *project.Module) error { continue } usageDiag := diag.BeginPhase(phase.Usage, module.Key) - usage.Analyze(p.ctx.WithDiagnostics(usageDiag), module) + usage.Analyze(ctx.WithDiagnostics(usageDiag), module) module.Phase = phase.Usage - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() } if err := requireScheduledModulesAtLeast(orderedModules, loader.scheduled, phase.Usage); err != nil { return err } - p.ctx.CompletedProjectPhase = phase.Usage - if p.ctx.Config.RequireEntrypoint { + ctx.CompletedProjectPhase = phase.Usage + if ctx.Config.RequireEntrypoint { validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.Key)) if diag.HasErrors() { return nil } } - p.advanceModulesThrough(orderedModules, prelude, preludeInjected, phase.Backend, diag) + advanceModulesThrough(ctx, orderedModules, prelude, preludeInjected, phase.Backend, diag) if diag != nil && diag.HasErrors() { return nil } @@ -163,15 +153,15 @@ func (p *Pipeline) Run(entry *project.Module) error { if err := requireScheduledModulesAtLeast(orderedModules, loader.scheduled, phase.Backend); err != nil { return err } - p.ctx.CompletedProjectPhase = phase.Backend + ctx.CompletedProjectPhase = phase.Backend mirModules := make([]*mir.Module, 0, len(orderedModules)) for _, module := range orderedModules { if module != nil && module.MIR != nil { mirModules = append(mirModules, module.MIR) } } - llvm.ValidateRuntimeSymbols(mirModules, finalDiag, p.ctx.Target) - p.ctx.CompletedProjectPhase = phase.Finalize + llvm.ValidateRuntimeSymbols(mirModules, finalDiag, ctx.Target) + ctx.CompletedProjectPhase = phase.Finalize return nil } @@ -199,19 +189,19 @@ func validateProgramEntrypoint(entry *project.Module, diag *diagnostics.Diagnost } } -func (p *Pipeline) advanceModulesThrough(orderedModules []*project.Module, prelude *project.Module, preludeInjected bool, lastPhase phase.Phase, diag *diagnostics.DiagnosticBag) bool { +func advanceModulesThrough(ctx *project.CompilerContext, orderedModules []*project.Module, prelude *project.Module, preludeInjected bool, lastPhase phase.Phase, diag *diagnostics.DiagnosticBag) bool { for { if !preludeInjected && prelude != nil && prelude.ModuleScope != nil && prelude.Phase >= phase.Collected { // Inject prelude as soon as its module scope exists. Other modules can // then resolve global prelude names while later binding updates the same // symbol objects in place. - p.injectPreludeSymbols(prelude, diag) + injectPreludeSymbols(ctx, prelude, diag) preludeInjected = true } ready := make([]*project.Module, 0, len(orderedModules)) for _, module := range orderedModules { - if module != nil && module.Phase < lastPhase && nextModulePhase(module.Phase) <= lastPhase && p.moduleReadyForNextPhase(module, prelude, preludeInjected) { + if module != nil && module.Phase < lastPhase && nextModulePhase(module.Phase) <= lastPhase && moduleReadyForNextPhase(ctx, module, prelude, preludeInjected) { ready = append(ready, module) } } @@ -225,7 +215,7 @@ func (p *Pipeline) advanceModulesThrough(orderedModules []*project.Module, prelu wg.Add(1) go func(module *project.Module) { defer wg.Done() - progress <- p.advanceModulePhase(module, diag) + progress <- advanceModulePhase(ctx, module, diag) }(module) } wg.Wait() @@ -235,7 +225,7 @@ func (p *Pipeline) advanceModulesThrough(orderedModules []*project.Module, prelu for ok := range progress { advanced = advanced || ok } - p.invalidateSemanticDependents(ready) + invalidateSemanticDependents(ctx, ready) if !advanced { break } @@ -245,20 +235,20 @@ func (p *Pipeline) advanceModulesThrough(orderedModules []*project.Module, prelu // injectPreludeSymbols keeps repeated pipeline runs idempotent while exposing // a real collision between compiler-owned globals and prelude declarations. -func (p *Pipeline) injectPreludeSymbols(prelude *project.Module, diag *diagnostics.DiagnosticBag) { - if p == nil || p.ctx == nil || p.ctx.GlobalScope == nil || prelude == nil || prelude.ModuleScope == nil { +func injectPreludeSymbols(ctx *project.CompilerContext, prelude *project.Module, diag *diagnostics.DiagnosticBag) { + if ctx == nil || ctx.GlobalScope == nil || prelude == nil || prelude.ModuleScope == nil { return } preludeDiag := diag.AppendPhase(phase.Collected, prelude.Key) for _, sym := range prelude.ModuleScope.Symbols() { - if err := p.ctx.GlobalScope.Declare(sym); err == nil { + if err := ctx.GlobalScope.Declare(sym); err == nil { continue } - existing, found := p.ctx.GlobalScope.LookupLocal(sym.Name) + existing, found := ctx.GlobalScope.LookupLocal(sym.Name) if found && existing != nil && existing.ID == sym.ID { continue } - problems.ReportRedeclaration(preludeDiag, p.ctx.GlobalScope, fmt.Sprintf("prelude declaration %q conflicts with an existing global", sym.Name), sym.Name, sym.Location) + problems.ReportRedeclaration(preludeDiag, ctx.GlobalScope, fmt.Sprintf("prelude declaration %q conflicts with an existing global", sym.Name), sym.Name, sym.Location) } } @@ -281,8 +271,8 @@ func requireScheduledModulesAtLeast(modules []*project.Module, scheduled map[str return nil } -func (p *Pipeline) moduleReadyForNextPhase(module, prelude *project.Module, preludeInjected bool) bool { - if p == nil || module == nil || module.AST == nil || module.Phase >= phase.Backend { +func moduleReadyForNextPhase(ctx *project.CompilerContext, module, prelude *project.Module, preludeInjected bool) bool { + if ctx == nil || module == nil || module.AST == nil || module.Phase >= phase.Backend { return false } next := nextModulePhase(module.Phase) @@ -297,7 +287,7 @@ func (p *Pipeline) moduleReadyForNextPhase(module, prelude *project.Module, prel return true } for _, imp := range module.Imports { - imported, ok := p.ctx.ModuleByKey(imp.Key) + imported, ok := ctx.ModuleByKey(imp.Key) if !ok || imported == nil || imported.Phase < required { return false } @@ -378,8 +368,8 @@ func importPrerequisitePhase(next phase.Phase) phase.Phase { // advanceModulePhase moves one module exactly one phase forward. Serial Run uses // same kernel that future dependency-aware scheduling will reuse, so phase // prerequisites stay centralized in one place. -func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics.DiagnosticBag) bool { - if p == nil || module == nil || module.AST == nil { +func advanceModulePhase(ctx *project.CompilerContext, module *project.Module, diag *diagnostics.DiagnosticBag) bool { + if ctx == nil || module == nil || module.AST == nil { return false } if module.Phase >= phase.Backend { @@ -390,29 +380,29 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. return false } phaseDiag := diag.BeginPhase(next, module.Key) - phaseCtx := p.ctx.WithDiagnostics(phaseDiag) + phaseCtx := ctx.WithDiagnostics(phaseDiag) if module.Phase < phase.Collected { collector.Collect(phaseCtx, module) module.Phase = phase.Collected - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.Bound { binder.Bind(phaseCtx, module) module.Phase = phase.Bound - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.Resolved { resolver.Resolve(phaseCtx, module) module.Phase = phase.Resolved - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.ConstEval { consteval.Evaluate(phaseCtx, module) module.Phase = phase.ConstEval - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.Typechecked { @@ -421,7 +411,7 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. module.TypedASTNodes = ast.Index(module.AST) module.SemanticExportFingerprint = project.SemanticExportFingerprint(module) module.Phase = phase.Typechecked - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.CFG { @@ -442,7 +432,7 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. return value != nil && value.Truthy(), ok }) module.Phase = phase.CFG - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.CFG == nil { @@ -457,13 +447,13 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. phaseDiag, ) module.Phase = phase.DefiniteInit - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.Ownership { module.Ownership = ownership.Check(phaseCtx, module) module.Phase = phase.Ownership - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.Phase < phase.Usage { @@ -479,7 +469,7 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. } module.HIR = fold.ApplyTypedExpressionFolding(modhir) module.Phase = phase.HIR - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.HIR == nil { @@ -491,7 +481,7 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. } module.MIR = mir.GenerateMIR(module.HIR, module.CFG, module.Ownership, module.ModuleScope, module.Semantics.ConstValues) module.Phase = phase.MIR - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } if module.MIR == nil { @@ -500,16 +490,16 @@ func (p *Pipeline) advanceModulePhase(module *project.Module, diag *diagnostics. if module.Phase >= phase.Backend { return false } - module.LLVMIR = llvm.GenerateLLVMIR(module.MIR, phaseDiag, p.ctx.Target, p.ctx.Config.BuildDebug) + module.LLVMIR = llvm.GenerateLLVMIR(module.MIR, phaseDiag, ctx.Target, ctx.Config.BuildDebug) module.Phase = phase.Backend - p.ctx.Metrics.AddPhaseAdvance() + ctx.Metrics.AddPhaseAdvance() return true } // invalidateSemanticDependents applies semantic API changes only between // parallel scheduler batches, after dependency type information is final. -func (p *Pipeline) invalidateSemanticDependents(advanced []*project.Module) { - if p == nil || p.ctx == nil || p.ctx.Graph == nil { +func invalidateSemanticDependents(ctx *project.CompilerContext, advanced []*project.Module) { + if ctx == nil || ctx.Graph == nil { return } queue := make([]graph.NodeID, 0) @@ -518,7 +508,7 @@ func (p *Pipeline) invalidateSemanticDependents(advanced []*project.Module) { if module == nil || module.Phase != phase.Typechecked { continue } - baseline, ok := p.ctx.SemanticExportBaseline(module.Key) + baseline, ok := ctx.SemanticExportBaseline(module.Key) if !ok || baseline == module.SemanticExportFingerprint { continue } @@ -529,18 +519,18 @@ func (p *Pipeline) invalidateSemanticDependents(advanced []*project.Module) { for len(queue) > 0 { current := queue[0] queue = queue[1:] - for _, dependentID := range p.ctx.Graph.Predecessors(current) { + for _, dependentID := range ctx.Graph.Predecessors(current) { if _, found := seen[dependentID]; found { continue } seen[dependentID] = struct{}{} queue = append(queue, dependentID) - dependent, found := p.ctx.ModuleByKey(string(dependentID)) + dependent, found := ctx.ModuleByKey(string(dependentID)) if !found || dependent == nil || dependent.Phase < phase.Typechecked { continue } - p.ctx.ResetModule(dependent, phase.Parsed) - p.ctx.Metrics.AddDowngradedModule() + ctx.ResetModule(dependent, phase.Parsed) + ctx.Metrics.AddDowngradedModule() } } } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 875a2d7..92357e4 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -50,7 +50,7 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e entry := parseModuleSource(entryPath, entrySrc, diag) entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } return diag @@ -84,7 +84,7 @@ func runImportedRuntimeSymbolPipeline(t *testing.T, entrySrc, runtimeSrc string) FilePath: entryPath, Origin: project.ModuleOriginLocal, } - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } return diag @@ -244,7 +244,7 @@ fn main() -> i32 { Imports: make(map[string]project.ResolvedImport), } - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -357,7 +357,7 @@ fn main() -> i32 { entry.ImportPath = "entry" entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -403,7 +403,7 @@ fn main() -> i32 { entry.ImportPath = "entry" entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if !diag.HasErrors() { @@ -441,13 +441,12 @@ fn main() -> i32 { return 0; }`) entry := parseModuleSource(filePath, `fn unused() {} fn main() -> i32 { return 0; }`, diag) entry.Origin = project.ModuleOriginLocal - pipeline := New(ctx) - if err := pipeline.Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("first Pipeline.Run: %v", err) } first := diag.WarningCount() - if err := pipeline.Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("second Pipeline.Run: %v", err) } if second := diag.WarningCount(); second != first { @@ -480,7 +479,7 @@ func TestPipelineRunReplacesStaleFinalizeDiagnostics(t *testing.T) { entry := parseModuleSource(filePath, sourceText, diag) entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("Pipeline.Run: %v", err) } if ctx.CompletedProjectPhase != phase.Finalize { @@ -522,7 +521,7 @@ func TestPipelineDebugBuildEmitsLLVMMetadata(t *testing.T) { entry.ImportPath = "entry" entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -546,7 +545,6 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { entry.Phase = phase.Parsed ctx.AddModule(entry) - pipeline := New(ctx) want := []phase.Phase{ phase.Collected, phase.Bound, @@ -558,7 +556,7 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { phase.Ownership, } for _, wantPhase := range want { - if !pipeline.advanceModulePhase(entry, diag) { + if !advanceModulePhase(ctx, entry, diag) { t.Fatalf("advanceModulePhase() stopped at %v, want %v", entry.Phase, wantPhase) } if entry.Phase != wantPhase { @@ -571,7 +569,7 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { t.Fatalf("phase %v produced HIR before mandatory semantics completed", wantPhase) } } - if pipeline.advanceModulePhase(entry, diag) { + if advanceModulePhase(ctx, entry, diag) { t.Fatal("per-module scheduler crossed project-wide Usage barrier") } entry.Phase = phase.Usage @@ -580,14 +578,14 @@ func TestPipelineAdvanceModulePhaseRunsOnePhaseAtATime(t *testing.T) { phase.MIR, phase.Backend, } { - if !pipeline.advanceModulePhase(entry, diag) { + if !advanceModulePhase(ctx, entry, diag) { t.Fatalf("advanceModulePhase() stopped at %v, want %v", entry.Phase, wantPhase) } if entry.Phase != wantPhase { t.Fatalf("phase = %v, want %v", entry.Phase, wantPhase) } } - if pipeline.advanceModulePhase(entry, diag) { + if advanceModulePhase(ctx, entry, diag) { t.Fatalf("advanceModulePhase() reported progress after backend phase") } if diag.HasErrors() { @@ -605,9 +603,8 @@ fn main() -> i32 { return Value; } entry.Phase = phase.Parsed ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) - pipeline := New(ctx) for entry.Phase < phase.ConstEval { - if !pipeline.advanceModulePhase(entry, diag) { + if !advanceModulePhase(ctx, entry, diag) { t.Fatalf("advanceModulePhase() stopped at %v", entry.Phase) } } @@ -620,7 +617,7 @@ fn main() -> i32 { return Value; } t.Fatal("failed to construct stale const value") } entry.Semantics.ConstValues[sym.ID] = stale - if !pipeline.advanceModulePhase(entry, diag) || entry.Phase != phase.Typechecked { + if !advanceModulePhase(ctx, entry, diag) || entry.Phase != phase.Typechecked { t.Fatalf("phase = %v, want typechecked", entry.Phase) } if got := entry.Semantics.ConstValues[sym.ID]; got == nil || got.TypeText() != "i32" { @@ -641,9 +638,8 @@ func TestPipelineFinalizesMissingReturnDiagnosticInCFGPhase(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) - pipeline := New(ctx) for entry.Phase < phase.CFG { - if !pipeline.advanceModulePhase(entry, diag) { + if !advanceModulePhase(ctx, entry, diag) { t.Fatalf("advanceModulePhase stopped at %v", entry.Phase) } } @@ -672,9 +668,8 @@ func TestPipelineReportsConstantConditionInCFGPhase(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) ctx.AddModule(entry) - pipeline := New(ctx) for entry.Phase < phase.CFG { - if !pipeline.advanceModulePhase(entry, diag) { + if !advanceModulePhase(ctx, entry, diag) { t.Fatalf("advanceModulePhase stopped at %v", entry.Phase) } } @@ -735,7 +730,7 @@ func TestPipelineDiagnosticStopReturnsNormally(t *testing.T) { entry := parseModuleSource("invalid"+peeper.SourceExt, "fn main() -> Missing { return 0; }", diag) entry.Origin = project.ModuleOriginLocal ctx := project.NewWithConfig(project.Config{RootDir: ".", Extension: peeper.SourceExt}, diag) - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("diagnostic-driven stop returned pipeline error: %v", err) } if !diag.HasErrors() { @@ -808,7 +803,7 @@ fn main() -> i32 { entry := parseModuleSource(entryPath, entrySrc, diag) entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -828,7 +823,6 @@ fn main() -> i32 { func TestPipelineModuleReadyForNextPhaseFollowsImportContracts(t *testing.T) { diag := diagnostics.NewDiagnosticBag() ctx := project.NewWithConfig(project.Config{RootDir: "."}, diag) - pipeline := New(ctx) imported := parseModuleSource("util"+peeper.SourceExt, "fn Helper() -> i32 { return 1; }", diag) imported.Origin = project.ModuleOriginLocal @@ -848,38 +842,38 @@ func TestPipelineModuleReadyForNextPhaseFollowsImportContracts(t *testing.T) { } ctx.AddModule(entry) - if !pipeline.moduleReadyForNextPhase(entry, nil, true) { + if !moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("parsed importer should be ready for collector when import is parsed") } entry.Phase = phase.Collected - if pipeline.moduleReadyForNextPhase(entry, nil, true) { + if moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("collected importer should wait for bound import before binder") } imported.Phase = phase.Bound - if !pipeline.moduleReadyForNextPhase(entry, nil, true) { + if !moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("collected importer should be ready for binder when import is bound") } entry.Phase = phase.Bound imported.Phase = phase.Parsed - if pipeline.moduleReadyForNextPhase(entry, nil, true) { + if moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("bound importer should wait for collected import before resolver") } imported.Phase = phase.Collected - if !pipeline.moduleReadyForNextPhase(entry, nil, true) { + if !moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("bound importer should be ready for resolver when import is collected") } entry.Phase = phase.Resolved - if pipeline.moduleReadyForNextPhase(entry, nil, true) { + if moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("resolved importer should wait for const-evaluated import before consteval") } imported.Phase = phase.ConstEval - if !pipeline.moduleReadyForNextPhase(entry, nil, true) { + if !moduleReadyForNextPhase(ctx, entry, nil, true) { t.Fatalf("resolved importer should be ready for consteval when import is const-evaluated") } } @@ -917,7 +911,7 @@ fn main() -> i32 { t.Fatalf("write main: %v", err) } - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -980,7 +974,7 @@ fn Value() -> i32 { FilePath: mainPath, Origin: project.ModuleOriginLocal, } - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -1333,7 +1327,7 @@ fn main() -> i32 { entry := parseModuleSource(entryPath, entrySrc, diag) entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -1501,7 +1495,7 @@ fn main() -> i32 { entry := parseModuleSource(entryPath, entrySrc, diag) entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { @@ -1964,7 +1958,7 @@ fn main() -> i32 { }, diag) entry := parseModuleSource(filePath, src, diag) entry.Origin = project.ModuleOriginLocal - if err := New(ctx).Run(entry); err != nil { + if err := Run(ctx, entry); err != nil { t.Fatalf("pipeline.Run returned error: %v", err) } if diag.HasErrors() { From 35ab979756dac68bc79d606ea1da568d2e21a635 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 16:00:48 +0600 Subject: [PATCH 09/12] Remove ignored compiler parameters Drop unused collector type syntax, import source-module context, and runtime target metadata from canonical signatures and all callers. --- internal/backend/llvm/emitter.go | 4 ++-- internal/backend/llvm/emitter_test.go | 2 ++ internal/lsp/workspace.go | 7 +------ internal/pipeline/loader.go | 2 +- internal/pipeline/pipeline.go | 2 +- internal/project/imports.go | 2 +- internal/project/imports_test.go | 6 +++--- internal/semantics/collector/collector.go | 11 +++++------ internal/semantics/collector/collector_test.go | 4 ++++ 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/backend/llvm/emitter.go b/internal/backend/llvm/emitter.go index eb49850..082ee7e 100644 --- a/internal/backend/llvm/emitter.go +++ b/internal/backend/llvm/emitter.go @@ -25,7 +25,7 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo } return "" } - if !ValidateRuntimeSymbols([]*mir.Module{mod}, diag, targetInfo) { + if !ValidateRuntimeSymbols([]*mir.Module{mod}, diag) { return "" } @@ -338,7 +338,7 @@ func GenerateLLVMIR(mod *mir.Module, diag *diagnostics.DiagnosticBag, targetInfo // ValidateRuntimeSymbols checks runtime ABI reservations after ownership and // lowering have made actual print, allocation, and destruction use explicit. -func ValidateRuntimeSymbols(modules []*mir.Module, diag *diagnostics.DiagnosticBag, targetInfo target.Info) bool { +func ValidateRuntimeSymbols(modules []*mir.Module, diag *diagnostics.DiagnosticBag) bool { printUsed := false dropUsed := false allocUsed := false diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index 98e18d5..12d3039 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -27,6 +27,8 @@ var ( testWindowsAMD64 = mustTestTarget("windows", "amd64") ) +var _ func([]*mir.Module, *diagnostics.DiagnosticBag) bool = ValidateRuntimeSymbols + type llvmTypeFixture struct { table *ir.TypeTable void, boolType, cstr, stringType, rawptr, i32 ir.TypeID diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index 9d09a3d..427dc82 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -143,18 +143,13 @@ func (w *workspaceIndex) rebuild(cache map[string]string) error { ProjectName: module.projectName, Extension: peeper.SourceExt, }, diagnostics.NewDiagnosticBag()) - from := &project.Module{ - FilePath: filePath, - ImportPath: module.importPath, - Origin: project.ModuleOriginLocal, - } seen := make(map[string]struct{}) for _, imp := range parsed.Imports { rawPath, ok := ast.ImportPathFromDecl(imp) if !ok { continue } - resolved, err := ctx.ResolveImportPath(from, rawPath) + resolved, err := ctx.ResolveImportPath(rawPath) if err != nil || resolved == nil || resolved.Origin != project.ModuleOriginLocal { continue } diff --git a/internal/pipeline/loader.go b/internal/pipeline/loader.go index cdc88b6..d758278 100644 --- a/internal/pipeline/loader.go +++ b/internal/pipeline/loader.go @@ -132,7 +132,7 @@ func (l *moduleLoader) resolveImports(module *project.Module, diag *diagnostics. l.addImportError(diag, imp, diagnostics.ErrInvalidImportPath, "invalid import path") continue } - resolved, err := l.ctx.ResolveImportPath(module, rawPath) + resolved, err := l.ctx.ResolveImportPath(rawPath) if err != nil { l.addImportResolveError(diag, imp, err) continue diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index c9b707a..1b4965f 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -160,7 +160,7 @@ func Run(ctx *project.CompilerContext, entry *project.Module) error { mirModules = append(mirModules, module.MIR) } } - llvm.ValidateRuntimeSymbols(mirModules, finalDiag, ctx.Target) + llvm.ValidateRuntimeSymbols(mirModules, finalDiag) ctx.CompletedProjectPhase = phase.Finalize return nil } diff --git a/internal/project/imports.go b/internal/project/imports.go index 30bd847..74a1dd2 100644 --- a/internal/project/imports.go +++ b/internal/project/imports.go @@ -251,7 +251,7 @@ func (ctx *CompilerContext) ImportPathForFile(origin ModuleOrigin, namespace, fi } // ResolveImportPath resolves an import path to a module file. -func (ctx *CompilerContext) ResolveImportPath(from *Module, rawPath string) (*ResolvedImport, error) { +func (ctx *CompilerContext) ResolveImportPath(rawPath string) (*ResolvedImport, error) { if ctx == nil { return nil, &ImportError{Code: diagnostics.ErrInvalidImportPath, Msg: "nil compiler context"} } diff --git a/internal/project/imports_test.go b/internal/project/imports_test.go index 48dc07b..f8ee14c 100644 --- a/internal/project/imports_test.go +++ b/internal/project/imports_test.go @@ -26,7 +26,7 @@ func TestResolveImportPathUsesLibraryNamespaceRoots(t *testing.T) { LibraryBaseDir: libraryBase, }, nil) - resolved, err := ctx.ResolveImportPath(nil, "vendor:json") + resolved, err := ctx.ResolveImportPath("vendor:json") if err != nil { t.Fatalf("ResolveImportPath() error = %v", err) } @@ -48,7 +48,7 @@ func TestResolveImportPathRequiresProjectConfigForLocalImports(t *testing.T) { Extension: peeper.SourceExt, }, nil) - _, err := ctx.ResolveImportPath(nil, "app/util") + _, err := ctx.ResolveImportPath("app/util") if err == nil { t.Fatal("expected local import error without project config") } @@ -73,7 +73,7 @@ func TestResolveImportPathStripsProjectPrefix(t *testing.T) { Extension: peeper.SourceExt, }, nil) - resolved, err := ctx.ResolveImportPath(nil, "app/util") + resolved, err := ctx.ResolveImportPath("app/util") if err != nil { t.Fatalf("ResolveImportPath() error = %v", err) } diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index f4644f3..d5e31f3 100644 --- a/internal/semantics/collector/collector.go +++ b/internal/semantics/collector/collector.go @@ -42,8 +42,7 @@ func (c *collector) collectModule(mod *ast.Module) { func (c *collector) collectNode(node ast.Node) { if decl, ok := node.(ast.TypeDecl); ok { if name := decl.DeclName(); name != nil { - typ := decl.UnderlyingType() - c.collectConcreteTypeDecl(name, typ, node) + c.collectConcreteTypeDecl(name, node) return } } @@ -51,9 +50,9 @@ func (c *collector) collectNode(node ast.Node) { case *ast.FnDecl: c.collectFnDecl(n) case *ast.LetDecl: - c.collectModuleBinding(n.Name, symbols.SymbolVar, n.Type, n) + c.collectModuleBinding(n.Name, symbols.SymbolVar, n) case *ast.ConstDecl: - c.collectModuleBinding(n.Name, symbols.SymbolConst, n.Type, n) + c.collectModuleBinding(n.Name, symbols.SymbolConst, n) default: return } @@ -102,7 +101,7 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { } } -func (c *collector) collectConcreteTypeDecl(name *ast.Ident, typ ast.TypeExpr, node ast.Node) { +func (c *collector) collectConcreteTypeDecl(name *ast.Ident, node ast.Node) { if c == nil || c.module == nil || node == nil { return } @@ -121,7 +120,7 @@ func (c *collector) collectConcreteTypeDecl(name *ast.Ident, typ ast.TypeExpr, n } } -func (c *collector) collectModuleBinding(name *ast.Ident, kind symbols.Kind, typ ast.TypeExpr, node ast.Node) { +func (c *collector) collectModuleBinding(name *ast.Ident, kind symbols.Kind, node ast.Node) { if c == nil || c.module == nil || name == nil || name.Name == "" { return } diff --git a/internal/semantics/collector/collector_test.go b/internal/semantics/collector/collector_test.go index eaa7cdf..2cc1e91 100644 --- a/internal/semantics/collector/collector_test.go +++ b/internal/semantics/collector/collector_test.go @@ -5,6 +5,7 @@ import ( "testing" "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/project" @@ -12,6 +13,9 @@ import ( "compiler/pkg/peeper" ) +var _ func(*collector, *ast.Ident, ast.Node) = (*collector).collectConcreteTypeDecl +var _ func(*collector, *ast.Ident, symbols.Kind, ast.Node) = (*collector).collectModuleBinding + func TestCallableSymbolsKeepDefiningModuleKey(t *testing.T) { const filePath = "collector_callable_module_test" + peeper.SourceExt const src = `struct Counter { value: i32 } From 4951f449061f98118645608087be2e1bb8fc19e5 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 16:10:45 +0600 Subject: [PATCH 10/12] Centralize IR source metadata --- internal/ir/constfold.go | 107 +++---- internal/ir/fold_test.go | 66 ++-- internal/ir/hir/lower/lower_interface.go | 14 +- internal/ir/hir/lower/module_lower.go | 181 ++++++----- internal/ir/mir/module_lower_test.go | 16 +- internal/ir/nodes.go | 388 +++++++---------------- internal/ir/nodes_test.go | 30 ++ 7 files changed, 336 insertions(+), 466 deletions(-) diff --git a/internal/ir/constfold.go b/internal/ir/constfold.go index e435648..0887bc6 100644 --- a/internal/ir/constfold.go +++ b/internal/ir/constfold.go @@ -21,7 +21,7 @@ func FoldExpr(types *TypeTable, expr Expr, env map[string]constvalue.Value) Expr case *InvalidExpr, *IntLit, *FloatLit, *StringLit, *BoolLit, *ZeroValue: return expr case *OptionalSome: - return &OptionalSome{Value: FoldExpr(types, node.Value, env), Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &OptionalSome{Value: FoldExpr(types, node.Value, env), Type: node.Type, SourceInfo: node.SourceInfo} case *Ident: if env != nil { if value, ok := env[node.Name]; ok && value != nil { @@ -36,7 +36,7 @@ func FoldExpr(types *TypeTable, expr Expr, env map[string]constvalue.Value) Expr return constValueExprAt(folded, node.Type, node.Origin()) } } - return &Unary{Op: node.Op, Arg: arg, Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &Unary{Op: node.Op, Arg: arg, Type: node.Type, SourceInfo: node.SourceInfo} case *Binary: left := FoldExpr(types, node.Left, env) right := FoldExpr(types, node.Right, env) @@ -47,25 +47,24 @@ func FoldExpr(types *TypeTable, expr Expr, env map[string]constvalue.Value) Expr return constValueExprAt(folded, node.Type, node.Origin()) } } - return &Binary{Op: node.Op, Left: left, Right: right, Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &Binary{Op: node.Op, Left: left, Right: right, Type: node.Type, SourceInfo: node.SourceInfo} case *Call: return &Call{ - Callee: FoldExpr(types, node.Callee, env), - Args: foldExprs(types, node.Args, env), - Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + Callee: FoldExpr(types, node.Callee, env), + Args: foldExprs(types, node.Args, env), + Type: node.Type, + SourceInfo: node.SourceInfo, } case *Load: - return &Load{Place: FoldPlace(types, node.Place, env), DropRoot: node.DropRoot, NodeID: node.NodeID, Location: node.Location} + return &Load{Place: FoldPlace(types, node.Place, env), DropRoot: node.DropRoot, SourceInfo: node.SourceInfo} case *AddrOf: - return &AddrOf{Place: FoldPlace(types, node.Place, env), Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &AddrOf{Place: FoldPlace(types, node.Place, env), Type: node.Type, SourceInfo: node.SourceInfo} case *TempBorrow: - return &TempBorrow{Value: FoldExpr(types, node.Value, env), Slice: node.Slice, Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &TempBorrow{Value: FoldExpr(types, node.Value, env), Slice: node.Slice, Type: node.Type, SourceInfo: node.SourceInfo} case *Len: - return &Len{Value: FoldExpr(types, node.Value, env), Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &Len{Value: FoldExpr(types, node.Value, env), Type: node.Type, SourceInfo: node.SourceInfo} case *StringChars: - return &StringChars{Value: FoldExpr(types, node.Value, env), Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &StringChars{Value: FoldExpr(types, node.Value, env), Type: node.Type, SourceInfo: node.SourceInfo} case *SliceView: return &SliceView{ Source: FoldPlace(types, node.Source, env), @@ -73,65 +72,59 @@ func FoldExpr(types *TypeTable, expr Expr, env map[string]constvalue.Value) Expr End: FoldExpr(types, node.End, env), EndExclusive: node.EndExclusive, Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + SourceInfo: node.SourceInfo, } case *InterfaceMake: return &InterfaceMake{ - Value: FoldExpr(types, node.Value, env), - Slots: node.Slots, - Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + Value: FoldExpr(types, node.Value, env), + Slots: node.Slots, + Type: node.Type, + SourceInfo: node.SourceInfo, } case *InterfaceCall: return &InterfaceCall{ - Base: FoldExpr(types, node.Base, env), - Slot: node.Slot, - Args: foldExprs(types, node.Args, env), - Consumes: node.Consumes, - Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + Base: FoldExpr(types, node.Base, env), + Slot: node.Slot, + Args: foldExprs(types, node.Args, env), + Consumes: node.Consumes, + Type: node.Type, + SourceInfo: node.SourceInfo, } case *Field: return &Field{ - Base: FoldExpr(types, node.Base, env), - Index: node.Index, - DropBase: node.DropBase, - Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + Base: FoldExpr(types, node.Base, env), + Index: node.Index, + DropBase: node.DropBase, + Type: node.Type, + SourceInfo: node.SourceInfo, } case *StructLit: - return &StructLit{Fields: foldExprs(types, node.Fields, env), Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &StructLit{Fields: foldExprs(types, node.Fields, env), Type: node.Type, SourceInfo: node.SourceInfo} case *ArrayLit: - return &ArrayLit{Values: foldExprs(types, node.Values, env), Dynamic: node.Dynamic, Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &ArrayLit{Values: foldExprs(types, node.Values, env), Dynamic: node.Dynamic, Type: node.Type, SourceInfo: node.SourceInfo} case *DynamicArrayOp: return &DynamicArrayOp{ - Op: node.Op, - Array: FoldExpr(types, node.Array, env), - Length: FoldExpr(types, node.Length, env), - Value: FoldExpr(types, node.Value, env), - ArrayType: node.ArrayType, - Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + Op: node.Op, + Array: FoldExpr(types, node.Array, env), + Length: FoldExpr(types, node.Length, env), + Value: FoldExpr(types, node.Value, env), + ArrayType: node.ArrayType, + Type: node.Type, + SourceInfo: node.SourceInfo, } case *AllocExpr: return &AllocExpr{ - Value: FoldExpr(types, node.Value, env), - Allocator: FoldExpr(types, node.Allocator, env), - Type: node.Type, - NodeID: node.NodeID, - Location: node.Location, + Value: FoldExpr(types, node.Value, env), + Allocator: FoldExpr(types, node.Allocator, env), + Type: node.Type, + SourceInfo: node.SourceInfo, } case *Cast: - return &Cast{Expr: FoldExpr(types, node.Expr, env), Type: node.Type, NodeID: node.NodeID, Location: node.Location} + return &Cast{Expr: FoldExpr(types, node.Expr, env), Type: node.Type, SourceInfo: node.SourceInfo} case *Print: - return &Print{Value: FoldExpr(types, node.Value, env), Newline: node.Newline, NodeID: node.NodeID, Location: node.Location} + return &Print{Value: FoldExpr(types, node.Value, env), Newline: node.Newline, SourceInfo: node.SourceInfo} case *Drop: - return &Drop{Value: FoldExpr(types, node.Value, env), NodeID: node.NodeID, Location: node.Location} + return &Drop{Value: FoldExpr(types, node.Value, env), SourceInfo: node.SourceInfo} default: panic(fmt.Sprintf("unhandled IR expression %T in constant folding", expr)) } @@ -171,18 +164,18 @@ func constValueExprAt(value constvalue.Value, typ TypeID, origin SourceInfo) Exp switch node := value.(type) { case *constvalue.IntConst: if node == nil { - return &IntLit{Value: "0", Type: typ, NodeID: origin.NodeID, Location: origin.Location} + return &IntLit{Value: "0", Type: typ, SourceInfo: origin} } - return &IntLit{Value: node.Text(), Type: typ, NodeID: origin.NodeID, Location: origin.Location} + return &IntLit{Value: node.Text(), Type: typ, SourceInfo: origin} case *constvalue.FloatConst: if node == nil { - return &FloatLit{Value: "0.0", Type: typ, NodeID: origin.NodeID, Location: origin.Location} + return &FloatLit{Value: "0.0", Type: typ, SourceInfo: origin} } - return &FloatLit{Value: node.Text(), Type: typ, NodeID: origin.NodeID, Location: origin.Location} + return &FloatLit{Value: node.Text(), Type: typ, SourceInfo: origin} case *constvalue.BoolConst: - return &BoolLit{Value: node != nil && node.Bool(), Type: typ, NodeID: origin.NodeID, Location: origin.Location} + return &BoolLit{Value: node != nil && node.Bool(), Type: typ, SourceInfo: origin} default: - return &InvalidExpr{Message: "unknown constant", Type: InvalidType, NodeID: origin.NodeID, Location: origin.Location} + return &InvalidExpr{Message: "unknown constant", Type: InvalidType, SourceInfo: origin} } } diff --git a/internal/ir/fold_test.go b/internal/ir/fold_test.go index a1e3a38..4965f6f 100644 --- a/internal/ir/fold_test.go +++ b/internal/ir/fold_test.go @@ -42,11 +42,11 @@ func TestFoldExprPreservesExpressionOrigin(t *testing.T) { types := NewTypeTable() i32 := types.Intern(Type{Kind: TypeInteger, Signed: true, Bits: 32}) expr := &Binary{ - Op: "+", - Left: &IntLit{Value: "2", Type: i32}, - Right: &IntLit{Value: "3", Type: i32}, - Type: i32, - NodeID: 73, + Op: "+", + Left: &IntLit{Value: "2", Type: i32}, + Right: &IntLit{Value: "3", Type: i32}, + Type: i32, + SourceInfo: SourceInfo{NodeID: 73}, } folded, ok := FoldExpr(types, expr, nil).(*IntLit) if !ok || folded.NodeID != expr.NodeID { @@ -114,9 +114,8 @@ func TestFoldExprPreservesLoadIdentity(t *testing.T) { }}, Type: i32, }, - DropRoot: true, - NodeID: 42, - Location: loc, + DropRoot: true, + SourceInfo: SourceInfo{NodeID: 42, Location: loc}, } folded, ok := FoldExpr(types, expr, nil).(*Load) @@ -138,12 +137,11 @@ func TestFoldExprFoldsEveryCompositeExpression(t *testing.T) { loc := &source.Location{} foldable := func() Expr { return &Binary{ - Op: "+", - Left: &IntLit{Value: "1", Type: i32}, - Right: &IntLit{Value: "2", Type: i32}, - Type: i32, - NodeID: 5, - Location: loc, + Op: "+", + Left: &IntLit{Value: "1", Type: i32}, + Right: &IntLit{Value: "2", Type: i32}, + Type: i32, + SourceInfo: SourceInfo{NodeID: 5, Location: loc}, } } place := func() *Place { @@ -162,26 +160,26 @@ func TestFoldExprFoldsEveryCompositeExpression(t *testing.T) { name string expr Expr }{ - {name: "optional", expr: &OptionalSome{Value: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "unary", expr: &Unary{Op: "opaque", Arg: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "binary", expr: &Binary{Op: "opaque", Left: foldable(), Right: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "call", expr: &Call{Callee: foldable(), Args: []Expr{foldable()}, Type: i32, NodeID: 9, Location: loc}}, - {name: "load", expr: &Load{Place: place(), DropRoot: true, NodeID: 9, Location: loc}}, - {name: "address", expr: &AddrOf{Place: place(), Type: i32, NodeID: 9, Location: loc}}, - {name: "temporary borrow", expr: &TempBorrow{Value: foldable(), Slice: true, Type: i32, NodeID: 9, Location: loc}}, - {name: "length", expr: &Len{Value: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "string chars", expr: &StringChars{Value: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "slice", expr: &SliceView{Source: place(), Start: foldable(), End: foldable(), EndExclusive: true, Type: i32, NodeID: 9, Location: loc}}, - {name: "interface make", expr: &InterfaceMake{Value: foldable(), Slots: []InterfaceSlot{{MethodName: "method"}}, Type: i32, NodeID: 9, Location: loc}}, - {name: "interface call", expr: &InterfaceCall{Base: foldable(), Slot: 2, Args: []Expr{foldable()}, Consumes: true, Type: i32, NodeID: 9, Location: loc}}, - {name: "field", expr: &Field{Base: foldable(), Index: 3, DropBase: true, Type: i32, NodeID: 9, Location: loc}}, - {name: "struct", expr: &StructLit{Fields: []Expr{foldable()}, Type: i32, NodeID: 9, Location: loc}}, - {name: "array", expr: &ArrayLit{Values: []Expr{foldable()}, Dynamic: true, Type: i32, NodeID: 9, Location: loc}}, - {name: "dynamic array operation", expr: &DynamicArrayOp{Array: foldable(), Length: foldable(), Value: foldable(), ArrayType: i32, Type: i32, NodeID: 9, Location: loc}}, - {name: "allocation", expr: &AllocExpr{Value: foldable(), Allocator: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "cast", expr: &Cast{Expr: foldable(), Type: i32, NodeID: 9, Location: loc}}, - {name: "print", expr: &Print{Value: foldable(), Newline: true, NodeID: 9, Location: loc}}, - {name: "drop", expr: &Drop{Value: foldable(), NodeID: 9, Location: loc}}, + {name: "optional", expr: &OptionalSome{Value: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "unary", expr: &Unary{Op: "opaque", Arg: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "binary", expr: &Binary{Op: "opaque", Left: foldable(), Right: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "call", expr: &Call{Callee: foldable(), Args: []Expr{foldable()}, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "load", expr: &Load{Place: place(), DropRoot: true, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "address", expr: &AddrOf{Place: place(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "temporary borrow", expr: &TempBorrow{Value: foldable(), Slice: true, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "length", expr: &Len{Value: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "string chars", expr: &StringChars{Value: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "slice", expr: &SliceView{Source: place(), Start: foldable(), End: foldable(), EndExclusive: true, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "interface make", expr: &InterfaceMake{Value: foldable(), Slots: []InterfaceSlot{{MethodName: "method"}}, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "interface call", expr: &InterfaceCall{Base: foldable(), Slot: 2, Args: []Expr{foldable()}, Consumes: true, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "field", expr: &Field{Base: foldable(), Index: 3, DropBase: true, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "struct", expr: &StructLit{Fields: []Expr{foldable()}, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "array", expr: &ArrayLit{Values: []Expr{foldable()}, Dynamic: true, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "dynamic array operation", expr: &DynamicArrayOp{Array: foldable(), Length: foldable(), Value: foldable(), ArrayType: i32, Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "allocation", expr: &AllocExpr{Value: foldable(), Allocator: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "cast", expr: &Cast{Expr: foldable(), Type: i32, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "print", expr: &Print{Value: foldable(), Newline: true, SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, + {name: "drop", expr: &Drop{Value: foldable(), SourceInfo: SourceInfo{NodeID: 9, Location: loc}}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/ir/hir/lower/lower_interface.go b/internal/ir/hir/lower/lower_interface.go index b7eab47..f85eab2 100644 --- a/internal/ir/hir/lower/lower_interface.go +++ b/internal/ir/hir/lower/lower_interface.go @@ -34,16 +34,16 @@ func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Modul slots := make([]ir.InterfaceSlot, 0, len(iface.Methods)) implementations := module.Semantics.InterfaceImplementations[expr.ID()] if len(implementations) != len(iface.Methods) { - return &ir.InvalidExpr{Message: "missing interface implementation evidence", Type: ir.InvalidType, Location: ast.LocOf(expr)} + return &ir.InvalidExpr{Message: "missing interface implementation evidence", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } for index, method := range iface.Methods { implementation := implementations[index] if implementation.MethodName != method.Name || implementation.CallableType == nil || implementation.Symbol == nil || implementation.OwnerKey == "" { - return &ir.InvalidExpr{Message: "missing interface method implementation", Type: ir.InvalidType, Location: ast.LocOf(expr)} + return &ir.InvalidExpr{Message: "missing interface method implementation", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } slotType, ok := interfaceSlotTypeID(ctx, module, method) if !ok { - return &ir.InvalidExpr{Message: "unsupported interface method shape", Type: ir.InvalidType, Location: ast.LocOf(expr)} + return &ir.InvalidExpr{Message: "unsupported interface method shape", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } slots = append(slots, ir.InterfaceSlot{ InterfaceType: loweredTypeID(ctx, module, expectedType), @@ -55,10 +55,10 @@ func maybeLowerInterfaceExpr(ctx *project.CompilerContext, module *project.Modul }) } return &ir.InterfaceMake{ - Value: lowerASTExpr(ctx, module, scope, expr, nil), - Slots: slots, - Type: loweredTypeID(ctx, module, expectedType), - Location: ast.LocOf(expr), + Value: lowerASTExpr(ctx, module, scope, expr, nil), + Slots: slots, + Type: loweredTypeID(ctx, module, expectedType), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}, } } diff --git a/internal/ir/hir/lower/module_lower.go b/internal/ir/hir/lower/module_lower.go index a822d20..6b66187 100644 --- a/internal/ir/hir/lower/module_lower.go +++ b/internal/ir/hir/lower/module_lower.go @@ -302,7 +302,7 @@ func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *sym if intConst, ok := value.(*constvalue.IntConst); ok && intConst != nil { indexType, ok := ctx.Types.LookupText(intConst.TypeText()) if ok { - indexExpr = &ir.IntLit{Value: intConst.Text(), Type: indexType, Location: ast.LocOf(index.Index)} + indexExpr = &ir.IntLit{Value: intConst.Text(), Type: indexType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(index.Index)}} } } } @@ -324,7 +324,7 @@ func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *sym func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, resultType typeinfo.Type, typeID ir.TypeID) ir.Expr { target, _, reference := typeinfo.ReferenceTarget(typeinfo.Underlying(resultType)) if !reference { - return &ir.InvalidExpr{Message: "reference lowering requires reference type", Type: ir.InvalidType, Location: ast.LocOf(expr)} + return &ir.InvalidExpr{Message: "reference lowering requires reference type", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } borrowAsView := false switch runtimeTarget := loweredRuntimeType(module, target, nil).(type) { @@ -338,17 +338,17 @@ func lowerReferenceValue(ctx *project.CompilerContext, module *project.Module, s } if !place.Addressable(scope, expr, exprType, expandedDefaultBindingResolver(module)) { return &ir.TempBorrow{ - Value: lowerASTExpr(ctx, module, scope, expr, target), - Slice: borrowAsView, - Type: typeID, - Location: ast.LocOf(expr), + Value: lowerASTExpr(ctx, module, scope, expr, target), + Slice: borrowAsView, + Type: typeID, + SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}, } } value := lowerPlace(ctx, module, scope, expr) if borrowAsView { - return &ir.SliceView{Source: value, Type: typeID, Location: ast.LocOf(expr)} + return &ir.SliceView{Source: value, Type: typeID, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } - return &ir.AddrOf{Place: value, Type: typeID, Location: ast.LocOf(expr)} + return &ir.AddrOf{Place: value, Type: typeID, SourceInfo: ir.SourceInfo{Location: ast.LocOf(expr)}} } func lowerImplicitReferenceValue(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, expr ast.Expr, resultType typeinfo.Type) ir.Expr { @@ -411,9 +411,9 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } if innerExpected := optionalSomeInnerType(module, expectedType, resolvedType, expr); innerExpected != nil { return &ir.OptionalSome{ - Value: lowerASTExpr(ctx, module, scope, expr, innerExpected), - Type: loweredTypeID(ctx, module, expectedType), - Location: loc, + Value: lowerASTExpr(ctx, module, scope, expr, innerExpected), + Type: loweredTypeID(ctx, module, expectedType), + SourceInfo: ir.SourceInfo{Location: loc}, } } if ifaceExpr := maybeLowerInterfaceExpr(ctx, module, scope, expr, expectedType); ifaceExpr != nil { @@ -422,7 +422,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s if expectedType != nil && resolvedType != nil && !typeinfo.SameType(expectedType, resolvedType) && typeinfo.CheckNumericCompatibility(expectedType, resolvedType) == typeinfo.Compatible { value := lowerASTExpr(ctx, module, scope, expr, nil) - return &ir.Cast{Expr: value, Type: loweredTypeID(ctx, module, expectedType), Location: loc} + return &ir.Cast{Expr: value, Type: loweredTypeID(ctx, module, expectedType), SourceInfo: ir.SourceInfo{Location: loc}} } expectedTypeID := loweredTypeID(ctx, module, expectedType) @@ -443,23 +443,23 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s t = loweredTypeID(ctx, module, &typeinfo.StringType{}) } } - return &ir.StringLit{Value: node.Value, Type: t, Location: loc} + return &ir.StringLit{Value: node.Value, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.ByteLit: - return &ir.IntLit{Value: fmt.Sprintf("%d", node.Value[0]), Type: loweredTypeID(ctx, module, &typeinfo.ByteType{}), Location: loc} + return &ir.IntLit{Value: fmt.Sprintf("%d", node.Value[0]), Type: loweredTypeID(ctx, module, &typeinfo.ByteType{}), SourceInfo: ir.SourceInfo{Location: loc}} case *ast.CharLit: runeValue, _ := utf8.DecodeRuneInString(node.Value) - return &ir.IntLit{Value: fmt.Sprintf("%d", runeValue), Type: loweredTypeID(ctx, module, &typeinfo.CharType{}), Location: loc} + return &ir.IntLit{Value: fmt.Sprintf("%d", runeValue), Type: loweredTypeID(ctx, module, &typeinfo.CharType{}), SourceInfo: ir.SourceInfo{Location: loc}} case *ast.BoolLit: - return &ir.BoolLit{Value: node.Value, Type: loweredTypeID(ctx, module, &typeinfo.BoolType{}), Location: loc} + return &ir.BoolLit{Value: node.Value, Type: loweredTypeID(ctx, module, &typeinfo.BoolType{}), SourceInfo: ir.SourceInfo{Location: loc}} case *ast.NoneLit: if none := lowerOptionalNone(ctx, expectedTypeID, loc); none != nil { return none } - return &ir.InvalidExpr{Message: "`none` requires optional context", Type: ir.InvalidType, Location: loc} + return &ir.InvalidExpr{Message: "`none` requires optional context", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.Ident: var sym *symbols.Symbol @@ -472,7 +472,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s sym, ok = scope.Lookup(node.Name) } if !ok || sym == nil { - return &ir.InvalidExpr{Message: "unresolved identifier: " + node.Name, Type: ir.InvalidType, Location: loc} + return &ir.InvalidExpr{Message: "unresolved identifier: " + node.Name, Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: loc}} } t := resolvedTypeID if t == ir.InvalidType { @@ -482,7 +482,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s t = ir.InvalidType } } - return &ir.Ident{Name: symbolName(module, sym), Type: t, SymbolID: sym.ID, Location: loc} + return &ir.Ident{Name: symbolName(module, sym), Type: t, SymbolID: sym.ID, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.ScopeResolution: var sym *symbols.Symbol @@ -503,9 +503,9 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s t = ir.InvalidType } } - return &ir.Ident{Name: symbolName(module, sym), Type: t, SymbolID: sym.ID, Location: loc} + return &ir.Ident{Name: symbolName(module, sym), Type: t, SymbolID: sym.ID, SourceInfo: ir.SourceInfo{Location: loc}} } - return &ir.InvalidExpr{Message: "unresolved qualified identifier: " + node.Module.Name + "::" + node.Name.Name, Type: ir.InvalidType, Location: loc} + return &ir.InvalidExpr{Message: "unresolved qualified identifier: " + node.Module.Name + "::" + node.Name.Name, Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.UnaryExpr: arg := lowerASTExpr(ctx, module, scope, node.Expr, expectedType) @@ -516,7 +516,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s t = loweredTypeID(ctx, module, &typeinfo.BoolType{}) } } - return &ir.Unary{Op: node.Op, Arg: arg, Type: t, Location: loc} + return &ir.Unary{Op: node.Op, Arg: arg, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.AddressExpr: t := resolvedTypeID @@ -534,7 +534,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s if node.Mode == ast.AddressShared || node.Mode == ast.AddressMutable { return lowerReferenceValue(ctx, module, scope, node.Expr, resolvedType, t) } - return &ir.AddrOf{Place: lowerPlace(ctx, module, scope, node.Expr), Type: t, Location: loc} + return &ir.AddrOf{Place: lowerPlace(ctx, module, scope, node.Expr), Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.BinaryExpr: leftExpected := expectedType @@ -582,7 +582,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s t = loweredTypeID(ctx, module, &typeinfo.BoolType{}) } } - return &ir.Binary{Op: node.Op, Left: left, Right: right, Type: t, Location: loc} + return &ir.Binary{Op: node.Op, Left: left, Right: right, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.CallExpr: if compilerCall, ok := module.Semantics.CompilerCalls[node.ID()]; ok { @@ -636,13 +636,13 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s } } } - return &ir.Call{Callee: calleeExpr, Args: args, Type: t, Location: loc} + return &ir.Call{Callee: calleeExpr, Args: args, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.PrintExpr: - return &ir.Print{Value: lowerASTExpr(ctx, module, scope, node.Expr, nil), Newline: node.Newline, Location: loc} + return &ir.Print{Value: lowerASTExpr(ctx, module, scope, node.Expr, nil), Newline: node.Newline, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.FreeExpr: - return &ir.Drop{Value: lowerASTExpr(ctx, module, scope, node.Expr, nil), Location: loc} + return &ir.Drop{Value: lowerASTExpr(ctx, module, scope, node.Expr, nil), SourceInfo: ir.SourceInfo{Location: loc}} case *ast.AsExpr: t := resolvedTypeID @@ -650,7 +650,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s t = loweredTypeID(ctx, module, typeinfo.TypeFromSyntax(node.TypeExpr, typeinfo.SyntaxOptions{Target: ctx.Target, AllowAbstractSelf: true})) } subExpr := lowerASTExpr(ctx, module, scope, node.Expr, expectedType) - return &ir.Cast{Expr: subExpr, Type: t, Location: loc} + return &ir.Cast{Expr: subExpr, Type: t, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.SelectorExpr: return lowerSelectorExpr(ctx, module, scope, node) @@ -665,7 +665,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s return lowerArrayLiteralExpr(ctx, module, scope, node) case *ast.BadExpr: - return &ir.InvalidExpr{Message: "unsupported expression", Type: ir.InvalidType, Location: loc} + return &ir.InvalidExpr{Message: "unsupported expression", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: loc}} default: panic(fmt.Sprintf("HIR lowering: unhandled expression %T", expr)) @@ -675,7 +675,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *s func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, call *ast.CallExpr, op symbols.CompilerOp) ir.Expr { fnType, _ := exprResolvedType(module, call.Callee).(*typeinfo.FuncType) if fnType == nil || len(fnType.Params) != 1 { - return &ir.InvalidExpr{Message: "collection function type missing", Type: ir.InvalidType, Location: ast.LocOf(call)} + return &ir.InvalidExpr{Message: "collection function type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} } value := call.Args[0] var receiver ir.Expr @@ -686,21 +686,21 @@ func lowerCollectionCall(ctx *project.CompilerContext, module *project.Module, s } switch op { case symbols.CompilerOpLen: - return &ir.Len{Value: receiver, Type: loweredReturnTypeID(ctx, module, fnType.Return), Location: ast.LocOf(call)} + return &ir.Len{Value: receiver, Type: loweredReturnTypeID(ctx, module, fnType.Return), SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} case symbols.CompilerOpAsBytes: return &ir.SliceView{ - Source: &ir.Place{Root: receiver, Type: receiver.TypeID(), Location: ast.LocOf(value)}, - Type: loweredReturnTypeID(ctx, module, fnType.Return), - Location: ast.LocOf(call), + Source: &ir.Place{Root: receiver, Type: receiver.TypeID(), Location: ast.LocOf(value)}, + Type: loweredReturnTypeID(ctx, module, fnType.Return), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}, } case symbols.CompilerOpAsChars: return &ir.StringChars{ - Value: receiver, - Type: loweredReturnTypeID(ctx, module, fnType.Return), - Location: ast.LocOf(call), + Value: receiver, + Type: loweredReturnTypeID(ctx, module, fnType.Return), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}, } default: - return &ir.InvalidExpr{Message: "unsupported collection function lowering", Type: ir.InvalidType, Location: ast.LocOf(call)} + return &ir.InvalidExpr{Message: "unsupported collection function lowering", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}} } } @@ -712,7 +712,7 @@ func lowerOptionalNone(ctx *project.CompilerContext, typeID ir.TypeID, loc *sour if !ok || typ.Kind != ir.TypeOptional { return nil } - return &ir.ZeroValue{Type: typeID, Location: loc} + return &ir.ZeroValue{Type: typeID, SourceInfo: ir.SourceInfo{Location: loc}} } func optionalSomeInnerType(module *project.Module, expectedType, resolvedType typeinfo.Type, expr ast.Expr) typeinfo.Type { @@ -757,12 +757,12 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul consumes = !borrowedReceiver } return &ir.InterfaceCall{ - Base: lowerASTExpr(ctx, module, scope, selector.Expr, nil), - Slot: slot, - Args: args, - Consumes: consumes, - Type: loweredReturnTypeID(ctx, module, iface.Return), - Location: ast.LocOf(call), + Base: lowerASTExpr(ctx, module, scope, selector.Expr, nil), + Slot: slot, + Args: args, + Consumes: consumes, + Type: loweredReturnTypeID(ctx, module, iface.Return), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}, } } methodSym := module.Semantics.ResolvedSymbols[selector.Name.ID()] @@ -790,14 +790,14 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul } return &ir.Call{ Callee: &ir.Ident{ - Name: symbolName(module, methodSym), - Type: loweredTypeID(ctx, module, fnType), - SymbolID: methodSym.ID, - Location: ast.LocOf(selector.Name), + Name: symbolName(module, methodSym), + Type: loweredTypeID(ctx, module, fnType), + SymbolID: methodSym.ID, + SourceInfo: ir.SourceInfo{Location: ast.LocOf(selector.Name)}, }, - Args: args, - Type: loweredReturnTypeID(ctx, module, fnType.Return), - Location: ast.LocOf(call), + Args: args, + Type: loweredReturnTypeID(ctx, module, fnType.Return), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(call)}, } } @@ -815,22 +815,21 @@ func lowerSelectorExpr(ctx *project.CompilerContext, module *project.Module, sco return exprResolvedType(module, expr) } if throughPtr || place.Addressable(scope, selector.Expr, exprType, expandedDefaultBindingResolver(module)) { - return &ir.Load{Place: lowerPlace(ctx, module, scope, selector), NodeID: ir.NodeID(selector.ID()), Location: ast.LocOf(selector)} + return &ir.Load{Place: lowerPlace(ctx, module, scope, selector), SourceInfo: ir.SourceInfo{NodeID: ir.NodeID(selector.ID()), Location: ast.LocOf(selector)}} } return &ir.Field{ - Base: lowerASTExpr(ctx, module, scope, selector.Expr, nil), - Index: fieldIndex, - NodeID: ir.NodeID(selector.ID()), - Type: loweredTypeID(ctx, module, field.Type), - Location: ast.LocOf(selector), + Base: lowerASTExpr(ctx, module, scope, selector.Expr, nil), + Index: fieldIndex, + SourceInfo: ir.SourceInfo{NodeID: ir.NodeID(selector.ID()), Location: ast.LocOf(selector)}, + Type: loweredTypeID(ctx, module, field.Type), } } - return &ir.InvalidExpr{Message: "selector lowering not implemented", Type: ir.InvalidType, Location: ast.LocOf(selector)} + return &ir.InvalidExpr{Message: "selector lowering not implemented", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(selector)}} } func lowerIndexExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.IndexExpr) ir.Expr { if module == nil || node == nil || node.Expr == nil || node.Index == nil { - return &ir.InvalidExpr{Message: "invalid index", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "invalid index", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } if rangeIndex, ok := node.Index.(*ast.RangeExpr); ok && rangeIndex != nil { var start, end ir.Expr @@ -854,20 +853,20 @@ func lowerIndexExpr(ctx *project.CompilerContext, module *project.Module, scope End: end, EndExclusive: rangeIndex.EndExclusive, Type: loweredTypeID(ctx, module, resultType), - Location: ast.LocOf(node), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}, } } - return &ir.Load{Place: lowerPlace(ctx, module, scope, node), NodeID: ir.NodeID(node.ID()), Location: ast.LocOf(node)} + return &ir.Load{Place: lowerPlace(ctx, module, scope, node), SourceInfo: ir.SourceInfo{NodeID: ir.NodeID(node.ID()), Location: ast.LocOf(node)}} } func lowerStructLiteralExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.StructLit) ir.Expr { if module == nil || node == nil { - return &ir.InvalidExpr{Message: "invalid struct literal", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "invalid struct literal", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } resolved := exprResolvedType(module, node) strct, ok := loweredRuntimeType(module, resolved, nil).(*typeinfo.StructType) if !ok || strct == nil { - return &ir.InvalidExpr{Message: "struct literal type missing", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "struct literal type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } fieldsByName := make(map[string]ast.Expr, len(node.Fields)) for _, field := range node.Fields { @@ -880,42 +879,42 @@ func lowerStructLiteralExpr(ctx *project.CompilerContext, module *project.Module for _, field := range strct.Fields { value, ok := fieldsByName[field.Name] if !ok { - return &ir.InvalidExpr{Message: "struct literal field missing during lowering", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "struct literal field missing during lowering", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } values = append(values, lowerASTExpr(ctx, module, scope, value, field.Type)) } return &ir.StructLit{ - Fields: values, - Type: loweredTypeID(ctx, module, resolved), - Location: ast.LocOf(node), + Fields: values, + Type: loweredTypeID(ctx, module, resolved), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}, } } func lowerArrayLiteralExpr(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.ArrayLit) ir.Expr { if module == nil || node == nil { - return &ir.InvalidExpr{Message: "invalid array literal", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "invalid array literal", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } resolved := exprResolvedType(module, node) array, ok := loweredRuntimeType(module, resolved, nil).(*typeinfo.ArrayType) if !ok || array == nil || array.Elem == nil { - return &ir.InvalidExpr{Message: "array literal type missing", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "array literal type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } values := make([]ir.Expr, 0, len(node.Values)) for _, value := range node.Values { values = append(values, lowerASTExpr(ctx, module, scope, value, array.Elem)) } return &ir.ArrayLit{ - Values: values, - Dynamic: array.Shape == typeinfo.ArrayOwner, - Type: loweredTypeID(ctx, module, resolved), - Location: ast.LocOf(node), + Values: values, + Dynamic: array.Shape == typeinfo.ArrayOwner, + Type: loweredTypeID(ctx, module, resolved), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}, } } func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr, op symbols.CompilerOp) ir.Expr { fnType, _ := typeinfo.Underlying(exprResolvedType(module, node.Callee)).(*typeinfo.FuncType) if fnType == nil || len(fnType.Params) != len(node.Args) || len(node.Args) < 2 { - return &ir.InvalidExpr{Message: "dynamic-array operation type missing", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "dynamic-array operation type missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } args := make([]ir.Expr, 0, len(node.Args)) for i, arg := range node.Args { @@ -927,14 +926,14 @@ func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Mo } ownerType, _, referenced := typeinfo.ReferenceTarget(typeinfo.Underlying(fnType.Params[0])) if !referenced { - return &ir.InvalidExpr{Message: "dynamic-array owner reference missing", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "dynamic-array owner reference missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } out := &ir.DynamicArrayOp{ - Op: op, - Array: args[0], - ArrayType: loweredTypeID(ctx, module, ownerType), - Type: loweredReturnTypeID(ctx, module, nil), - Location: ast.LocOf(node), + Op: op, + Array: args[0], + ArrayType: loweredTypeID(ctx, module, ownerType), + Type: loweredReturnTypeID(ctx, module, nil), + SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}, } switch op { case symbols.CompilerOpAppend: @@ -943,7 +942,7 @@ func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Mo out.Length = args[1] case symbols.CompilerOpResize: if len(args) != 3 { - return &ir.InvalidExpr{Message: "resize operation arguments missing", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "resize operation arguments missing", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } out.Length = args[1] out.Value = args[2] @@ -955,7 +954,7 @@ func lowerDynamicArrayOwnerCall(ctx *project.CompilerContext, module *project.Mo func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope *symbols.Scope, node *ast.CallExpr) ir.Expr { if len(node.Args) < 1 || len(node.Args) > 2 { - return &ir.InvalidExpr{Message: "alloc requires 1 or 2 arguments", Type: ir.InvalidType, Location: ast.LocOf(node)} + return &ir.InvalidExpr{Message: "alloc requires 1 or 2 arguments", Type: ir.InvalidType, SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}} } value := lowerASTExpr(ctx, module, scope, node.Args[0], nil) var allocator ir.Expr @@ -964,10 +963,10 @@ func lowerAllocCall(ctx *project.CompilerContext, module *project.Module, scope } resultType := loweredTypeID(ctx, module, exprResolvedType(module, node)) return &ir.AllocExpr{ - Value: value, - Allocator: allocator, - Type: resultType, - Location: ast.LocOf(node), + Value: value, + Allocator: allocator, + Type: resultType, + SourceInfo: ir.SourceInfo{Location: ast.LocOf(node)}, } } @@ -991,9 +990,9 @@ func lowerNumberLit(ctx *project.CompilerContext, module *project.Module, node * if expectedType == nil || typeinfo.IsInvalidOrUnknown(expectedType) { // No expected type - use language default. if numeric.IsFloat(node.Value) { - return &ir.FloatLit{Value: node.Value, Type: loweredTypeID(ctx, module, typeinfo.DefaultNumberType(node.Value)), Location: loc} + return &ir.FloatLit{Value: node.Value, Type: loweredTypeID(ctx, module, typeinfo.DefaultNumberType(node.Value)), SourceInfo: ir.SourceInfo{Location: loc}} } - return &ir.IntLit{Value: integerValue, Type: loweredTypeID(ctx, module, typeinfo.DefaultNumberType(node.Value)), Location: loc} + return &ir.IntLit{Value: integerValue, Type: loweredTypeID(ctx, module, typeinfo.DefaultNumberType(node.Value)), SourceInfo: ir.SourceInfo{Location: loc}} } family, _, numericType := typeinfo.NumericInfo(expectedType) if numericType && family == typeinfo.NumericFloat { @@ -1001,9 +1000,9 @@ func lowerNumberLit(ctx *project.CompilerContext, module *project.Module, node * if !numeric.IsFloat(node.Value) { v = integerValue + ".0" } - return &ir.FloatLit{Value: v, Type: loweredTypeID(ctx, module, expectedType), Location: loc} + return &ir.FloatLit{Value: v, Type: loweredTypeID(ctx, module, expectedType), SourceInfo: ir.SourceInfo{Location: loc}} } - return &ir.IntLit{Value: integerValue, Type: loweredTypeID(ctx, module, expectedType), Location: loc} + return &ir.IntLit{Value: integerValue, Type: loweredTypeID(ctx, module, expectedType), SourceInfo: ir.SourceInfo{Location: loc}} } func symbolName(module *project.Module, sym *symbols.Symbol) string { diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 03817cb..f0fc433 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -985,15 +985,15 @@ func TestGenerateMIRPreservesNestedExpressionLocations(t *testing.T) { Value: &ir.Binary{ Op: "*", Left: &ir.Binary{ - Op: "+", - Left: &ir.IntLit{Value: "1", Type: mirTypes.i32, Location: source.NewLocation(testPath, source.Position{Line: 2, Column: 2}, source.Position{Line: 2, Column: 3})}, - Right: &ir.IntLit{Value: "2", Type: mirTypes.i32, Location: source.NewLocation(testPath, source.Position{Line: 2, Column: 6}, source.Position{Line: 2, Column: 7})}, - Type: mirTypes.i32, - Location: source.NewLocation(testPath, source.Position{Line: 2, Column: 2}, source.Position{Line: 2, Column: 7}), + Op: "+", + Left: &ir.IntLit{Value: "1", Type: mirTypes.i32, SourceInfo: ir.SourceInfo{Location: source.NewLocation(testPath, source.Position{Line: 2, Column: 2}, source.Position{Line: 2, Column: 3})}}, + Right: &ir.IntLit{Value: "2", Type: mirTypes.i32, SourceInfo: ir.SourceInfo{Location: source.NewLocation(testPath, source.Position{Line: 2, Column: 6}, source.Position{Line: 2, Column: 7})}}, + Type: mirTypes.i32, + SourceInfo: ir.SourceInfo{Location: source.NewLocation(testPath, source.Position{Line: 2, Column: 2}, source.Position{Line: 2, Column: 7})}, }, - Right: &ir.IntLit{Value: "3", Type: mirTypes.i32, Location: source.NewLocation(testPath, source.Position{Line: 3, Column: 2}, source.Position{Line: 3, Column: 3})}, - Type: mirTypes.i32, - Location: source.NewLocation(testPath, source.Position{Line: 3, Column: 2}, source.Position{Line: 3, Column: 7}), + Right: &ir.IntLit{Value: "3", Type: mirTypes.i32, SourceInfo: ir.SourceInfo{Location: source.NewLocation(testPath, source.Position{Line: 3, Column: 2}, source.Position{Line: 3, Column: 3})}}, + Type: mirTypes.i32, + SourceInfo: ir.SourceInfo{Location: source.NewLocation(testPath, source.Position{Line: 3, Column: 2}, source.Position{Line: 3, Column: 7})}, }, Location: source.NewLocation(testPath, source.Position{Line: 4, Column: 2}, source.Position{Line: 4, Column: 8}), }, diff --git a/internal/ir/nodes.go b/internal/ir/nodes.go index ac2726c..b3d40eb 100644 --- a/internal/ir/nodes.go +++ b/internal/ir/nodes.go @@ -2,6 +2,7 @@ package ir import ( "fmt" + "reflect" "strings" "compiler/internal/semantics/symbols" @@ -20,6 +21,14 @@ type SourceInfo struct { Location *source.Location } +func (info SourceInfo) Origin() SourceInfo { return info } + +func (info *SourceInfo) setOrigin(origin SourceInfo) { + if info != nil { + *info = origin + } +} + type Param struct { Name string Type TypeID @@ -36,84 +45,73 @@ type Expr interface { } type InvalidExpr struct { - Message string - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Message string + Type TypeID } type IntLit struct { - Value string - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value string + Type TypeID } type FloatLit struct { - Value string - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value string + Type TypeID } type StringLit struct { - Value string - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value string + Type TypeID } type BoolLit struct { - Value bool - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value bool + Type TypeID } type ZeroValue struct { - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Type TypeID } type OptionalSome struct { - Value Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value Expr + Type TypeID } type Ident struct { + SourceInfo Name string Type TypeID SymbolID symbols.SymbolID - NodeID NodeID - Location *source.Location } type Unary struct { - Op string - Arg Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Op string + Arg Expr + Type TypeID } type Binary struct { - Op string - Left Expr - Right Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Op string + Left Expr + Right Expr + Type TypeID } type Call struct { - Callee Expr - Args []Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Callee Expr + Args []Expr + Type TypeID } type PlaceProjectionKind uint8 @@ -140,51 +138,45 @@ type Place struct { } type Load struct { + SourceInfo Place *Place DropRoot bool - NodeID NodeID - Location *source.Location } type AddrOf struct { - Place *Place - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Place *Place + Type TypeID } type TempBorrow struct { - Value Expr - Slice bool - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value Expr + Slice bool + Type TypeID } type Len struct { - Value Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value Expr + Type TypeID } // StringChars decodes a borrowed string into an owned dynamic char array. type StringChars struct { - Value Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value Expr + Type TypeID } // SliceView shapes array storage into a non-owning reference value. type SliceView struct { + SourceInfo Source *Place Start Expr End Expr EndExclusive bool Type TypeID - NodeID NodeID - Location *source.Location } type InterfaceSlot struct { @@ -198,85 +190,105 @@ type InterfaceSlot struct { } type InterfaceMake struct { - Value Expr - Slots []InterfaceSlot - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Value Expr + Slots []InterfaceSlot + Type TypeID } type InterfaceCall struct { + SourceInfo Base Expr Slot int Args []Expr Consumes bool Type TypeID - NodeID NodeID - Location *source.Location } type Field struct { + SourceInfo Base Expr Index int DropBase bool - NodeID NodeID Type TypeID - Location *source.Location } type StructLit struct { - Fields []Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Fields []Expr + Type TypeID } type ArrayLit struct { - Values []Expr - Dynamic bool - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Values []Expr + Dynamic bool + Type TypeID } type DynamicArrayOp struct { + SourceInfo Op symbols.CompilerOp Array Expr Length Expr Value Expr ArrayType TypeID Type TypeID - NodeID NodeID - Location *source.Location } type AllocExpr struct { + SourceInfo Value Expr Allocator Expr Type TypeID - NodeID NodeID - Location *source.Location } type Cast struct { - Expr Expr - Type TypeID - NodeID NodeID - Location *source.Location + SourceInfo + Expr Expr + Type TypeID } type Print struct { - Value Expr - Newline bool - NodeID NodeID - Location *source.Location + SourceInfo + Value Expr + Newline bool } type Drop struct { - Value Expr - NodeID NodeID - Location *source.Location -} + SourceInfo + Value Expr +} + +var ( + _ Expr = (*InvalidExpr)(nil) + _ Expr = (*IntLit)(nil) + _ Expr = (*FloatLit)(nil) + _ Expr = (*StringLit)(nil) + _ Expr = (*BoolLit)(nil) + _ Expr = (*ZeroValue)(nil) + _ Expr = (*OptionalSome)(nil) + _ Expr = (*Ident)(nil) + _ Expr = (*Unary)(nil) + _ Expr = (*Binary)(nil) + _ Expr = (*Call)(nil) + _ Expr = (*Load)(nil) + _ Expr = (*AddrOf)(nil) + _ Expr = (*TempBorrow)(nil) + _ Expr = (*Len)(nil) + _ Expr = (*StringChars)(nil) + _ Expr = (*SliceView)(nil) + _ Expr = (*InterfaceMake)(nil) + _ Expr = (*InterfaceCall)(nil) + _ Expr = (*Field)(nil) + _ Expr = (*StructLit)(nil) + _ Expr = (*ArrayLit)(nil) + _ Expr = (*DynamicArrayOp)(nil) + _ Expr = (*AllocExpr)(nil) + _ Expr = (*Cast)(nil) + _ Expr = (*Print)(nil) + _ Expr = (*Drop)(nil) +) func (*InvalidExpr) exprNode() {} func (*InvalidExpr) forEachChild(func(Expr)) {} @@ -375,179 +387,17 @@ func (p *Place) forEachChild(visit func(Expr)) { } } -func exprSource(nodeID NodeID, loc *source.Location) SourceInfo { - return SourceInfo{NodeID: nodeID, Location: loc} -} - -func (e *InvalidExpr) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *InvalidExpr) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *IntLit) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *IntLit) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *FloatLit) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *FloatLit) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *StringLit) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *StringLit) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *BoolLit) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *BoolLit) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *ZeroValue) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *ZeroValue) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *OptionalSome) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *OptionalSome) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Ident) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Ident) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Unary) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Unary) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Binary) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Binary) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Call) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Call) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Load) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Load) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *AddrOf) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *AddrOf) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *TempBorrow) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *TempBorrow) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Len) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Len) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *StringChars) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *StringChars) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *SliceView) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *SliceView) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *InterfaceMake) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *InterfaceMake) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *InterfaceCall) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *InterfaceCall) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Field) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Field) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *StructLit) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *StructLit) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *ArrayLit) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *ArrayLit) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *DynamicArrayOp) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *DynamicArrayOp) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *AllocExpr) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *AllocExpr) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Cast) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Cast) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Print) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Print) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} -func (e *Drop) Origin() SourceInfo { return exprSource(e.NodeID, e.Location) } -func (e *Drop) setOrigin(info SourceInfo) { - if e != nil { - e.NodeID, e.Location = info.NodeID, info.Location - } -} - // WithOrigin applies provenance at compiler phase boundaries, including // synthetic expressions returned by helper lowerers. func WithOrigin(expr Expr, info SourceInfo) Expr { - if expr != nil { - expr.setOrigin(info) + if expr == nil { + return nil + } + value := reflect.ValueOf(expr) + if value.Kind() == reflect.Pointer && value.IsNil() { + return expr } + expr.setOrigin(info) return expr } diff --git a/internal/ir/nodes_test.go b/internal/ir/nodes_test.go index ea6477a..cc9a4cd 100644 --- a/internal/ir/nodes_test.go +++ b/internal/ir/nodes_test.go @@ -2,8 +2,38 @@ package ir import ( "testing" + + "compiler/internal/source" ) +func TestSourceInfoOwnsExpressionOrigin(t *testing.T) { + location := &source.Location{} + expr := &IntLit{ + Value: "1", + SourceInfo: SourceInfo{NodeID: 7, Location: location}, + } + + if expr.NodeID != 7 || expr.Location != location { + t.Fatalf("promoted source fields = (%d, %p), want (7, %p)", expr.NodeID, expr.Location, location) + } + if got := expr.Origin(); got != expr.SourceInfo { + t.Fatalf("origin = %#v, want %#v", got, expr.SourceInfo) + } + + replacement := SourceInfo{NodeID: 9} + expr.setOrigin(replacement) + if expr.SourceInfo != replacement { + t.Fatalf("updated origin = %#v, want %#v", expr.SourceInfo, replacement) + } +} + +func TestWithOriginPreservesTypedNilExpression(t *testing.T) { + var expr Expr = (*IntLit)(nil) + if got := WithOrigin(expr, SourceInfo{NodeID: 9}); got != expr { + t.Fatalf("WithOrigin(typed nil) = %#v, want original typed nil", got) + } +} + func TestSignatureText(t *testing.T) { types := NewTypeTable() i32 := types.Intern(Type{Kind: TypeInteger, Signed: true, Bits: 32}) From 1d5ee16195c2e4edb02b373893f8d059fac478fd Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 16:14:38 +0600 Subject: [PATCH 11/12] Remove write-only graph nodes --- internal/graph/graph.go | 24 +------- internal/graph/graph_test.go | 59 +++++++++++++------ internal/lsp/workspace.go | 5 +- internal/project/context.go | 2 +- internal/project/modules.go | 10 +--- internal/semantics/binder/type_decl_cycles.go | 2 - 6 files changed, 46 insertions(+), 56 deletions(-) diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 5fe7077..0c6dffe 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -7,45 +7,23 @@ import ( type NodeID string -type NodeKind string type EdgeKind string -type Node struct { - Kind NodeKind -} - type Graph struct { mu sync.RWMutex - nodeKind NodeKind edgeKind EdgeKind - nodes map[NodeID]Node out map[NodeID]map[EdgeKind]map[NodeID]struct{} in map[NodeID]map[EdgeKind]map[NodeID]struct{} } -func New(nodeKind NodeKind, edgeKind EdgeKind) *Graph { +func New(edgeKind EdgeKind) *Graph { return &Graph{ - nodeKind: nodeKind, edgeKind: edgeKind, - nodes: make(map[NodeID]Node), out: make(map[NodeID]map[EdgeKind]map[NodeID]struct{}), in: make(map[NodeID]map[EdgeKind]map[NodeID]struct{}), } } -func (g *Graph) AddNode(id NodeID, kinds ...NodeKind) { - if g == nil || id == "" { - return - } - kind := g.nodeKind - if len(kinds) > 0 { - kind = kinds[0] - } - g.mu.Lock() - defer g.mu.Unlock() - g.nodes[id] = Node{Kind: kind} -} - func (g *Graph) AddEdge(from, to NodeID, kinds ...EdgeKind) { if g == nil || from == "" || to == "" { return diff --git a/internal/graph/graph_test.go b/internal/graph/graph_test.go index 52e36bd..884fdd7 100644 --- a/internal/graph/graph_test.go +++ b/internal/graph/graph_test.go @@ -6,15 +6,12 @@ import ( ) const ( - testNodeModule NodeKind = "module" - testEdgeImport EdgeKind = "import" + testEdgeImport EdgeKind = "import" + testEdgeMetadata EdgeKind = "metadata" ) func TestTopoSortOrdersImportDependencies(t *testing.T) { - g := New(testNodeModule, testEdgeImport) - g.AddNode("a") - g.AddNode("b") - g.AddNode("c") + g := New(testEdgeImport) g.AddEdge("a", "b") g.AddEdge("b", "c") @@ -28,9 +25,7 @@ func TestTopoSortOrdersImportDependencies(t *testing.T) { } func TestTopoSortReportsCycles(t *testing.T) { - g := New(testNodeModule, testEdgeImport) - g.AddNode("a") - g.AddNode("b") + g := New(testEdgeImport) g.AddEdge("a", "b") g.AddEdge("b", "a") @@ -41,12 +36,10 @@ func TestTopoSortReportsCycles(t *testing.T) { } func TestGraphDegreeAndPredecessorQueries(t *testing.T) { - g := New(testNodeModule, testEdgeImport) - g.AddNode("a") - g.AddNode("b") - g.AddNode("c") + g := New(testEdgeImport) g.AddEdge("a", "b") g.AddEdge("c", "b") + g.AddEdge("a", "c", testEdgeMetadata) if got := g.OutDegree("a"); got != 1 { t.Fatalf("unexpected out degree: %d", got) @@ -58,14 +51,16 @@ func TestGraphDegreeAndPredecessorQueries(t *testing.T) { if !slices.Contains(preds, NodeID("a")) || !slices.Contains(preds, NodeID("c")) { t.Fatalf("unexpected predecessors: %v", preds) } + if got := g.Successors("a", testEdgeMetadata); !slices.Equal(got, []NodeID{"c"}) { + t.Fatalf("metadata successors = %v, want [c]", got) + } + if got := g.OutDegree("a", testEdgeMetadata); got != 1 { + t.Fatalf("metadata out degree = %d, want 1", got) + } } func TestWeaklyConnectedComponents(t *testing.T) { - g := New(testNodeModule, testEdgeImport) - g.AddNode("a") - g.AddNode("b") - g.AddNode("c") - g.AddNode("d") + g := New(testEdgeImport) g.AddEdge("a", "b") g.AddEdge("c", "d") @@ -78,3 +73,31 @@ func TestWeaklyConnectedComponents(t *testing.T) { t.Fatalf("missing {a,b} component: %v", components) } } + +func TestAlgorithmsPreserveCallerProvidedIsolatedNodes(t *testing.T) { + g := New(testEdgeImport) + ids := []NodeID{"connected", "dependency", "isolated"} + g.AddEdge("connected", "dependency") + + order, cycles := g.TopoSort(ids) + if len(cycles) != 0 { + t.Fatalf("unexpected cycles: %v", cycles) + } + if len(order) != len(ids) || !slices.Contains(order, NodeID("isolated")) { + t.Fatalf("topological order lost isolated node: %v", order) + } + + components := g.WeaklyConnectedComponents(ids) + if len(components) != 2 { + t.Fatalf("components = %v, want connected pair plus isolated node", components) + } + foundIsolated := false + for _, component := range components { + if slices.Equal(component, []NodeID{"isolated"}) { + foundIsolated = true + } + } + if !foundIsolated { + t.Fatalf("components lost isolated node: %v", components) + } +} diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index 427dc82..44ba75f 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -173,10 +173,7 @@ func (w *workspaceIndex) rebuild(cache map[string]string) error { delete(w.modules, filePath) } - g := graph.New(project.GraphNodeModule, project.GraphEdgeImport) - for filePath := range w.modules { - g.AddNode(graph.NodeID(filePath)) - } + g := graph.New(project.GraphEdgeImport) for _, module := range w.modules { for _, target := range module.importTargets { if _, ok := w.modules[target]; !ok { diff --git a/internal/project/context.go b/internal/project/context.go index 7bab89b..2d13b10 100644 --- a/internal/project/context.go +++ b/internal/project/context.go @@ -163,7 +163,7 @@ func NewWithConfig(cfg Config, diag *diagnostics.DiagnosticBag) *CompilerContext Diagnostics: diag, CompletedProjectPhase: phase.Setup, GlobalScope: globalScope, - Graph: graph.New(GraphNodeModule, GraphEdgeImport), + Graph: graph.New(GraphEdgeImport), mu: &sync.RWMutex{}, modules: make(map[string]*Module), diff --git a/internal/project/modules.go b/internal/project/modules.go index bafe525..9472bbd 100644 --- a/internal/project/modules.go +++ b/internal/project/modules.go @@ -29,10 +29,7 @@ const ( ModuleOriginDependency ModuleOrigin = "dependency" ) -const ( - GraphNodeModule graph.NodeKind = "module" - GraphEdgeImport graph.EdgeKind = "import" -) +const GraphEdgeImport graph.EdgeKind = "import" // Source unit shared by every compiler phase. type Module struct { @@ -229,7 +226,7 @@ func (ctx *CompilerContext) NewModuleForFile(filePath, content string) *Module { return module } -// Register a module in the shared graph. +// Register a module in shared compiler state. func (ctx *CompilerContext) AddModule(module *Module) { if ctx == nil || module == nil || module.Key == "" { return @@ -241,9 +238,6 @@ func (ctx *CompilerContext) AddModule(module *Module) { if module.FilePath != "" { ctx.fileIndex[CanonicalPath(module.FilePath)] = module.Key } - if ctx.Graph != nil { - ctx.Graph.AddNode(graph.NodeID(module.Key)) - } } // Lookup by graph identity. diff --git a/internal/semantics/binder/type_decl_cycles.go b/internal/semantics/binder/type_decl_cycles.go index 5a7fe6b..3f14915 100644 --- a/internal/semantics/binder/type_decl_cycles.go +++ b/internal/semantics/binder/type_decl_cycles.go @@ -12,7 +12,6 @@ import ( ) const ( - graphNodeTypeDecl graph.NodeKind = "type_decl" graphEdgeTypeValueRef graph.EdgeKind = "type_value_ref" graphEdgeTypeIndirectRef graph.EdgeKind = "type_indirect_ref" ) @@ -22,7 +21,6 @@ func (b *binder) registerTypeDecl(name string, typ ast.TypeExpr) { return } owner := typeDeclNodeID(b.module.Key, name) - b.ctx.Graph.AddNode(owner, graphNodeTypeDecl) // Value edges require full layout; indirect references do not force target expansion. b.addTypeDeclEdges(owner, typ, false) } From d350d08d56033e634b7ef66d0b894baad1d0a9f3 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Sun, 23 Aug 2026 16:51:02 +0600 Subject: [PATCH 12/12] Wake LSP reader after output failure --- internal/lsp/completion_test.go | 3 +- internal/lsp/jsonrpc_test.go | 4 +-- internal/lsp/server.go | 11 ++++++- internal/lsp/server_test.go | 53 +++++++++++++++++++++++++++++---- internal/lsp/uri_test.go | 5 ++-- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/internal/lsp/completion_test.go b/internal/lsp/completion_test.go index 36297fc..cc5a81a 100644 --- a/internal/lsp/completion_test.go +++ b/internal/lsp/completion_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "encoding/json" + "io" "path/filepath" "slices" "strings" @@ -94,7 +95,7 @@ func TestCompletionAdvertisesTriggersAndDispatchesRequest(t *testing.T) { } var output bytes.Buffer - if err := Run(bytes.NewReader(input.Bytes()), &output); err != nil { + if err := Run(io.NopCloser(bytes.NewReader(input.Bytes())), &output); err != nil { t.Fatalf("Run failed: %v", err) } reader := bufio.NewReader(bytes.NewReader(output.Bytes())) diff --git a/internal/lsp/jsonrpc_test.go b/internal/lsp/jsonrpc_test.go index d986f48..5d6961e 100644 --- a/internal/lsp/jsonrpc_test.go +++ b/internal/lsp/jsonrpc_test.go @@ -101,7 +101,7 @@ func TestServerResponseResultAndErrorExclusivity(t *testing.T) { t.Fatalf("write request: %v", err) } var output bytes.Buffer - if err := Run(&input, &output); err != nil { + if err := Run(io.NopCloser(&input), &output); err != nil { t.Fatalf("Run: %v", err) } message, err := readMessage(bufio.NewReader(&output)) @@ -150,7 +150,7 @@ func TestRunReturnsResponseWriteFailure(t *testing.T) { } want := errors.New(tt.name + " write failed") output := &failingProtocolOutput{failAt: tt.failAt, err: want} - if err := Run(&input, output); !errors.Is(err, want) { + if err := Run(io.NopCloser(&input), output); !errors.Is(err, want) { t.Fatalf("Run error = %v, want %v", err, want) } }) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 13404e6..5c98162 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -18,10 +18,19 @@ import ( const LSP_VERSION = "0.0.1" const diagnosticsDebounceDelay = 150 * time.Millisecond -func Run(in io.Reader, out io.Writer) error { +func Run(in io.ReadCloser, out io.Writer) error { reader := bufio.NewReader(in) state := NewServerState() writer := newProtocolWriter(out) + sessionDone := make(chan struct{}) + defer close(sessionDone) + go func() { + select { + case <-writer.failureCh: + _ = in.Close() + case <-sessionDone: + } + }() for { if err := writer.writeError(); err != nil { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 75074de..fe05070 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -700,7 +700,7 @@ func TestRunReturnsSynchronousDiagnosticWriteFailure(t *testing.T) { } want := errors.New("diagnostic header failed") output := &failingProtocolOutput{failAt: 1, err: want} - if err := Run(&input, output); !errors.Is(err, want) { + if err := Run(io.NopCloser(&input), output); !errors.Is(err, want) { t.Fatalf("Run error = %v, want %v", err, want) } } @@ -747,13 +747,54 @@ func TestRunReturnsDebouncedDiagnosticWriteFailureOnProtocolEnd(t *testing.T) { } want := errors.New("debounced diagnostic header failed") output := &failingProtocolOutput{failAt: 3, err: want} - if err := Run(&input, output); !errors.Is(err, want) { + if err := Run(io.NopCloser(&input), output); !errors.Is(err, want) { t.Fatalf("Run error = %v, want %v", err, want) } }) } } +func TestRunReturnsDebouncedDiagnosticWriteFailureWithInputOpen(t *testing.T) { + root := t.TempDir() + filePath := filepath.Join(root, "main"+peeper.SourceExt) + changeParams, err := json.Marshal(DidChangeTextDocumentParams{ + TextDocument: VersionedTextDocumentIdentifier{ + URI: DocumentURI(pathToURI(filePath)), + Version: 1, + }, + ContentChanges: []TextDocumentContentChangeEvent{{Text: "fn main() {}\n"}}, + }) + if err != nil { + t.Fatalf("marshal change params: %v", err) + } + + inputReader, inputWriter := io.Pipe() + defer inputWriter.Close() + want := errors.New("debounced diagnostic write failed") + runDone := make(chan error, 1) + go func() { + runDone <- Run(inputReader, &failingProtocolOutput{failAt: 1, err: want}) + }() + if err := writeMessage(inputWriter, Request{ + JSONRPC: "2.0", + Method: "textDocument/didChange", + Params: changeParams, + }); err != nil { + t.Fatalf("write change request: %v", err) + } + + select { + case err := <-runDone: + if !errors.Is(err, want) { + t.Fatalf("Run error = %v, want %v", err, want) + } + case <-time.After(diagnosticsDebounceDelay + time.Second): + _ = inputWriter.Close() + err := <-runDone + t.Fatalf("Run remained blocked with input open; after close error = %v", err) + } +} + func TestHoverShowsExplicitTypeForImportedCallBinding(t *testing.T) { root := t.TempDir() writeWorkspaceProjectConfig(t, root, "app") @@ -1358,7 +1399,7 @@ func TestLSPInitializedPublishesDiagnosticsForUnopenedWorkspaceFiles(t *testing. } var output bytes.Buffer - if err := Run(bytes.NewReader(input.Bytes()), &output); err != nil { + if err := Run(io.NopCloser(bytes.NewReader(input.Bytes())), &output); err != nil { t.Fatalf("Run failed: %v", err) } @@ -1453,7 +1494,7 @@ func TestLSPDidChangeClearsDiagnosticsForFixedComponentFile(t *testing.T) { } var output bytes.Buffer - if err := Run(bytes.NewReader(input.Bytes()), &output); err != nil { + if err := Run(io.NopCloser(bytes.NewReader(input.Bytes())), &output); err != nil { t.Fatalf("Run failed: %v", err) } @@ -1514,7 +1555,7 @@ func TestLSPDidChangePublishesSyntaxErrorsAfterDebounce(t *testing.T) { } var output bytes.Buffer - if err := Run(bytes.NewReader(input.Bytes()), &output); err != nil { + if err := Run(io.NopCloser(bytes.NewReader(input.Bytes())), &output); err != nil { t.Fatalf("Run failed: %v", err) } @@ -1880,7 +1921,7 @@ func TestLSPDidChangePublishesInterfaceSeparatorErrorsAfterDebounce(t *testing.T } var output bytes.Buffer - if err := Run(bytes.NewReader(input.Bytes()), &output); err != nil { + if err := Run(io.NopCloser(bytes.NewReader(input.Bytes())), &output); err != nil { t.Fatalf("Run failed: %v", err) } diff --git a/internal/lsp/uri_test.go b/internal/lsp/uri_test.go index 5716e91..a21763c 100644 --- a/internal/lsp/uri_test.go +++ b/internal/lsp/uri_test.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "errors" + "io" "testing" ) @@ -113,7 +114,7 @@ func TestMalformedRequestURIMapsToInvalidParams(t *testing.T) { t.Fatalf("write request: %v", err) } var output bytes.Buffer - if err := Run(&input, &output); err != nil { + if err := Run(io.NopCloser(&input), &output); err != nil { t.Fatalf("Run: %v", err) } message, err := readMessage(bufio.NewReader(&output)) @@ -160,7 +161,7 @@ func TestMalformedNotificationURIDoesNotPublishOrMutateProtocolState(t *testing. } } var output bytes.Buffer - if err := Run(&input, &output); err != nil { + if err := Run(io.NopCloser(&input), &output); err != nil { t.Fatalf("Run: %v", err) } reader := bufio.NewReader(&output)