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/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/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..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 @@ -298,7 +308,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/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/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/internal/driver/compiler.go b/internal/driver/compiler.go index 02a5005..56d4bdc 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) @@ -52,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 } @@ -62,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/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/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/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 bf3a178..f85eab2 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 } @@ -34,31 +34,31 @@ 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), 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), }) } 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/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 0ab5934..6b66187 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" @@ -13,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" @@ -47,20 +48,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 - } + params, returnType := lowerExternSignature(ctx, module, sym.Scope, fn.ParamsWithReceiver(), fn.ReturnType, resolvedFnType) out.Externs = append(out.Externs, hir.Extern{ Name: emittedName, Params: params, @@ -80,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 := "" @@ -111,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 { @@ -138,7 +128,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 @@ -153,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 } @@ -170,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 @@ -197,7 +187,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 +207,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}) @@ -281,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 { @@ -312,7 +302,7 @@ func lowerPlace(ctx *project.CompilerContext, module *project.Module, scope *tab 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)}} } } } @@ -331,10 +321,10 @@ 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)} + 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) { @@ -348,20 +338,20 @@ 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 *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) @@ -369,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)} @@ -404,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} } @@ -421,9 +411,9 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t } 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 { @@ -432,7 +422,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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) @@ -453,23 +443,23 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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 @@ -482,7 +472,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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 { @@ -492,7 +482,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, SourceInfo: ir.SourceInfo{Location: loc}} case *ast.ScopeResolution: var sym *symbols.Symbol @@ -513,9 +503,9 @@ 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, 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) @@ -526,7 +516,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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 @@ -544,7 +534,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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 @@ -592,7 +582,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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 { @@ -646,13 +636,13 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t } } } - 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 @@ -660,7 +650,7 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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) @@ -675,17 +665,17 @@ func lowerASTExpr(ctx *project.CompilerContext, module *project.Module, scope *t 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)) } } -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)} + 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 @@ -696,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)}} } } @@ -722,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 { @@ -747,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} } @@ -767,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()] @@ -780,11 +770,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,18 +790,18 @@ func lowerSelectorMethodCall(ctx *project.CompilerContext, module *project.Modul } return &ir.Call{ Callee: &ir.Ident{ - Name: methodSymbolRefName(methodOwnerKey, 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)}, } } -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} } @@ -827,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 *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)} + 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 @@ -866,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 *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)} + 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 { @@ -892,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 *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)} + 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 *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)} + 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 { @@ -939,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: @@ -955,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] @@ -965,9 +952,9 @@ 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)} + 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 @@ -976,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)}, } } @@ -990,22 +977,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} @@ -1019,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 { @@ -1029,21 +1000,67 @@ 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(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 +1073,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/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..f0fc433 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 { @@ -986,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}) diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 9bd80b7..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" @@ -80,7 +79,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 @@ -375,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/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/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/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 fb36cd2..2557791 100644 --- a/internal/lsp/jsonrpc.go +++ b/internal/lsp/jsonrpc.go @@ -3,12 +3,17 @@ package lsp import ( "bufio" "encoding/json" + "errors" "fmt" "io" "strconv" "strings" + "sync" ) +// 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 +24,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"` } @@ -29,12 +34,67 @@ 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"` 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 { @@ -53,6 +113,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..5d6961e --- /dev/null +++ b/internal/lsp/jsonrpc_test.go @@ -0,0 +1,173 @@ +package lsp + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "testing" +) + +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") +} + +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") + 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, Params: params}); err != nil { + t.Fatalf("write request: %v", err) + } + var output bytes.Buffer + if err := Run(io.NopCloser(&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) + } + }) + } +} + +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(io.NopCloser(&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/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 6f7568b..5c98162 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -4,10 +4,11 @@ import ( "bufio" "encoding/json" "errors" + "fmt" "io" - "path/filepath" + "net/url" + "path" "strings" - "sync" "time" "compiler/internal/diagnostics" @@ -17,20 +18,40 @@ 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() - var outMu sync.Mutex + 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 { + 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 { @@ -43,14 +64,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 @@ -68,26 +97,36 @@ 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": 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) + if err := publishComponentDiagnostics(writer, state, filePath, nil); err != nil { + return err + } } 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() error { + return publishComponentDiagnostics(writer, state, filePath, nil) }) } continue @@ -95,9 +134,14 @@ 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) + if err := publishComponentDiagnostics(writer, state, filePath, nil); err != nil { + return err + } } continue @@ -106,10 +150,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 +161,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 +172,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,18 +183,20 @@ 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": result = nil case "exit": - state.waitForScheduledDiagnostics() - return nil + if err := state.waitForScheduledDiagnostics(); err != nil { + return err + } + return writer.writeError() default: if req.ID != nil { @@ -162,51 +208,91 @@ func Run(in io.Reader, out io.Writer) error { resp := Response{ JSONRPC: "2.0", ID: req.ID, - Result: result, Error: respErr, } - outMu.Lock() - _ = writeMessage(out, resp) - outMu.Unlock() + if respErr == nil { + resp.Result = &result + } + 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(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) } - return uri + 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(path string) string { - clean := filepath.ToSlash(filepath.Clean(path)) - if len(clean) > 0 && clean[0] != '/' { - return "file:///" + clean +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() } - return "file://" + clean + clean := path.Clean(slashPath) + if isWindowsDrivePath(clean) || !strings.HasPrefix(clean, "/") { + clean = "/" + clean + } + return (&url.URL{Scheme: "file", Path: clean}).String() } -func publishDiagnosticSnapshot(w io.Writer, writeMu *sync.Mutex, state *ServerState, snapshot *diagnosticSnapshot) { +func isWindowsDrivePath(filePath string) bool { + if len(filePath) < 3 || filePath[1] != ':' || filePath[2] != '/' { + return false + } + drive := filePath[0] + return drive >= 'A' && drive <= 'Z' || drive >= 'a' && drive <= 'z' +} + +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() @@ -215,17 +301,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 ae01b44..fe05070 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" @@ -16,6 +17,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" + "compiler/pkg/manifest" "compiler/pkg/peeper" ) @@ -116,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) @@ -645,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) @@ -670,6 +675,126 @@ 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(io.NopCloser(&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(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") @@ -1274,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) } @@ -1285,6 +1410,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") @@ -1336,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) } @@ -1397,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) } @@ -1426,12 +1584,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) @@ -1463,10 +1626,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: @@ -1487,7 +1649,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") } @@ -1662,18 +1827,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 { @@ -1749,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/state.go b/internal/lsp/state.go index 2bfa35d..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 @@ -149,9 +150,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 @@ -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 { diff --git a/internal/lsp/uri_test.go b/internal/lsp/uri_test.go new file mode 100644 index 0000000..a21763c --- /dev/null +++ b/internal/lsp/uri_test.go @@ -0,0 +1,174 @@ +package lsp + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "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(io.NopCloser(&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(io.NopCloser(&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") + } +} diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index 9d09a3d..44ba75f 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 } @@ -178,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/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 c85e03c..1b4965f 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -24,39 +24,31 @@ 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" ) -// 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") } - p.ctx.AddModule(entry) - p.ctx.CompletedProjectPhase = phase.Load - diag := p.ctx.Diagnostics + entry.IsEntry = true + 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 } @@ -69,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 { @@ -94,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 { @@ -125,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 } @@ -138,15 +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 - p.advanceModulesThrough(orderedModules, prelude, preludeInjected, phase.Backend, diag) + ctx.CompletedProjectPhase = phase.Usage + if ctx.Config.RequireEntrypoint { + validateProgramEntrypoint(entry, diag.AppendPhase(phase.Usage, entry.Key)) + if diag.HasErrors() { + return nil + } + } + advanceModulesThrough(ctx, orderedModules, prelude, preludeInjected, phase.Backend, diag) if diag != nil && diag.HasErrors() { return nil } @@ -155,31 +153,55 @@ 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.CompletedProjectPhase = phase.Finalize return nil } -func (p *Pipeline) advanceModulesThrough(orderedModules []*project.Module, prelude *project.Module, preludeInjected bool, lastPhase phase.Phase, diag *diagnostics.DiagnosticBag) bool { +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 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) } } @@ -193,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() @@ -203,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 } @@ -213,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) } } @@ -249,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) @@ -265,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 } @@ -346,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 { @@ -358,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 { @@ -389,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 { @@ -410,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 { @@ -425,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 { @@ -447,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 { @@ -459,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 { @@ -468,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) @@ -486,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 } @@ -497,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 1d4ff79..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 @@ -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") @@ -182,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() { @@ -256,12 +318,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) { @@ -292,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() { @@ -338,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() { @@ -376,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 { @@ -415,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 { @@ -457,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() { @@ -481,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, @@ -493,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 { @@ -506,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 @@ -515,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() { @@ -540,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) } } @@ -555,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" { @@ -576,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) } } @@ -607,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) } } @@ -670,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() { @@ -743,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() { @@ -763,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 @@ -783,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") } } @@ -852,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() { @@ -915,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() { @@ -1268,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() { @@ -1436,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() { @@ -1899,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() { 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 f94736e..2d13b10 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 @@ -84,6 +83,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. @@ -162,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), @@ -269,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") @@ -285,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 } @@ -305,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/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/project/modules.go b/internal/project/modules.go index f8eec78..9472bbd 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" ) @@ -30,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 { @@ -77,7 +73,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 +81,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 @@ -119,9 +115,21 @@ 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), + 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), @@ -218,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 @@ -230,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/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/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) } diff --git a/internal/semantics/collector/collector.go b/internal/semantics/collector/collector.go index a3c62c2..d5e31f3 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 == "" { @@ -43,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 } } @@ -52,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 } @@ -88,20 +86,22 @@ func (c *collector) collectFnDecl(fn *ast.FnDecl) { return } sym := symbols.New(fn.Name.Name, symbols.SymbolMethod, fn, ast.LocOf(fn.Name)) - sym.Scope = table.New(c.module.ModuleScope) + sym.DefiningModule = c.module.DefiningModuleKey() + 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.Scope = table.New(c.module.ModuleScope) + sym.DefiningModule = c.module.DefiningModuleKey() + 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 } } -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 } @@ -120,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 14e6cdd..2cc1e91 100644 --- a/internal/semantics/collector/collector_test.go +++ b/internal/semantics/collector/collector_test.go @@ -5,12 +5,53 @@ import ( "testing" "compiler/internal/diagnostics" + "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" "compiler/internal/project" + "compiler/internal/semantics/symbols" "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 } +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/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 1d73abe..3e01364 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 *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") 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 == "" { 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/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; +} 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; +}