From df70ef9b5b09114a09ff1f88ef4fb15f8d894e47 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sun, 4 Oct 2026 19:44:04 +0600 Subject: [PATCH 1/3] Rename overlays to source overrides --- docs/architecture/infrastructure.md | 20 ++-- docs/architecture/lsp.md | 76 +++++++----- internal/driver/compiler.go | 16 +-- internal/driver/compiler_test.go | 14 +-- internal/lsp/benchmark_test.go | 21 ++-- internal/lsp/completion.go | 22 ++-- internal/lsp/server.go | 4 +- internal/lsp/server_test.go | 100 +++++++++++++++- internal/lsp/state.go | 46 ++++---- internal/lsp/types.go | 4 + internal/lsp/workspace.go | 156 +++++++++--------------- internal/lsp/workspace_test.go | 176 +++++++++++++++++++++++++--- internal/pipeline/pipeline.go | 3 +- internal/pipeline/pipeline_test.go | 4 +- internal/prelude/prelude.go | 2 +- 15 files changed, 439 insertions(+), 225 deletions(-) diff --git a/docs/architecture/infrastructure.md b/docs/architecture/infrastructure.md index fbbddcd1..00f5c71d 100644 --- a/docs/architecture/infrastructure.md +++ b/docs/architecture/infrastructure.md @@ -9,7 +9,7 @@ This map records infrastructure implementation observed in `internal/module`, `i | `module` | Per-source artifact container, phase reset, and published semantic/IR handoff state. | | `project` | Compilation context, module registry, paths, imports, snapshots, fingerprints, semantic lookup, metrics. | | `pipeline` | Concurrent module loading, import graph construction, phase barriers, phase advancement, invalidation. | -| `driver` | Public compile entrypoints and source/overlay selection. | +| `driver` | Public compile entrypoints and disk/source override selection. | | `diagnostics` | Phase/module-scoped diagnostic storage, codes, labels, source cache, rendering, suggestions, highlighting. | | `graph` | Directed adjacency, reverse edges, topology algorithms, FIFO worklists. | | `problems` | Reusable construction of common semantic diagnostics. | @@ -144,9 +144,9 @@ can survive a parse-only reset; later pipeline phases rebuild dependent artifact ## Inputs, outputs, and loading `driver.CompileFile` is the ordinary entrypoint. It resolves an absolute input path, -reads disk content when `overlay == nil`, or uses the overlay string otherwise. It -creates or obtains the module, marks it as entry, runs `pipeline.Run`, and returns -the module. Read/path/pipeline failures are added to a load-scoped diagnostic bag. +reads disk content when `sourceOverride == nil`, or uses the source override string +otherwise. It creates or obtains the module, marks it as entry, runs `pipeline.Run`, +and returns the module. Read/path/pipeline failures are added to a load-scoped diagnostic bag. `driver.AddSource` registers virtual content without running the pipeline. `project.DiscoverSourceFiles` expands explicit files and directories, accepts the @@ -197,7 +197,7 @@ The pipeline then runs these project jobs: | Job | Input | Output / barrier | | --- | --- | --- | -| Load | entry, prelude, file/overlay content | registered modules, ASTs, imports, graph, `Load`/`Parsed` diagnostics. | +| Load | entry, prelude, disk/source override content | registered modules, ASTs, imports, graph, `Load`/`Parsed` diagnostics. | | Analysis-through | ordered modules and import readiness | phases through `Analyzed`; symbols, THIR, CFG, durable analysis facts and diagnostics. | | Usage | analyzed modules | usage diagnostics and `Usage` module/project barrier. | | Entrypoint check | entry module scope and function type | optional `ErrInvalidEntrypoint`. | @@ -346,11 +346,11 @@ both locations. It does not store or emit diagnostics itself beyond the supplied back to bounded file scanning. `diagnostics.SourceCache` stores split source lines under an RW mutex. The bag accepts -in-memory source through `AddSourceContent`, allowing diagnostics for overlays. The -emitter supports ANSI/HTML strings and stderr output, source snippets, labels, -code hints, tab expansion, and optional syntax highlighting. `SyntaxHighlighter` -performs lightweight line tokenization; `suggest.go` supplies Levenshtein-based, -ambiguity-aware nearest-name suggestions with stable priorities. +in-memory source through `AddSourceContent`, allowing diagnostics for source +overrides. The emitter supports ANSI/HTML strings and stderr output, source +snippets, labels, code hints, tab expansion, and optional syntax highlighting. +`SyntaxHighlighter` performs lightweight line tokenization; `suggest.go` supplies +Levenshtein-based, ambiguity-aware nearest-name suggestions with stable priorities. ## Graphs and worklists diff --git a/docs/architecture/lsp.md b/docs/architecture/lsp.md index 09d967bb..04d79268 100644 --- a/docs/architecture/lsp.md +++ b/docs/architecture/lsp.md @@ -8,8 +8,8 @@ Key implementation files: - `server.go` owns the session loop, method dispatch, diagnostic publication, and file URI/path conversion. -- `state.go` owns mutable server state, document overlays, compiler snapshots, - diagnostic generations, debounce workers, and module reuse. +- `state.go` owns mutable server state, document source overrides, compiler + snapshots, diagnostic generations, debounce workers, and module reuse. - `types.go` defines the LSP request, response, notification, position, range, document, diagnostic, hover, completion, rename, and workspace-edit payloads. - `jsonrpc.go` frames JSON-RPC messages and serializes writes. @@ -42,8 +42,8 @@ The supported lifecycle and document methods are: and publishes component diagnostics immediately. - `textDocument/didChange`: converts the URI, takes the first full-text change, stores its version, and schedules diagnostics after the debounce delay. -- `textDocument/didClose`: removes the overlay and version, then publishes the - resulting component diagnostics. +- `textDocument/didClose`: decodes `textDocument.uri`, removes the source override + and version, then publishes the resulting component diagnostics. - `textDocument/hover`, `definition`, `completion`, and `rename`: invoke the corresponding `ServerState` handler and return its result. - `shutdown`: returns a successful null result. @@ -75,17 +75,18 @@ completion requests. `ServerState` contains: -- `RootDir`, `Cache`, `LastCtx`, and `LastMetrics` for workspace configuration, - open-document text, the latest compiler context, and metrics. +- `RootDir`, `SourceOverrides`, `LastCtx`, and `LastMetrics` for workspace + configuration, open-document text, the latest compiler context, and metrics. - `modules` for retained compiled modules used by incremental recompilation. - `workspace` for the source/import index. - `documentVersions` for LSP document versions included in diagnostic notifications. -- `diagGeneration` for invalidating diagnostic snapshots after any overlay - mutation. +- `diagGeneration` for invalidating diagnostic snapshots after any source + override mutation. - `diagVersion` for per-file debounce cancellation. - `diagWG` and `diagErr` for waiting on workers and retaining their first error. -- `mu` for state, cache, compiler snapshot, workspace, and debounce metadata. +- `mu` for state, source overrides, compiler snapshot, workspace, and debounce + metadata. - `publishMu` for the publication/mutation boundary. `NewServerState` initializes the maps. Most state reads and writes use `mu`. @@ -102,17 +103,17 @@ The request loop itself is sequential, but diagnostic refreshes run in goroutine The protocol writer remains safe when those workers publish concurrently with the request loop. -## Document overlays and generations +## Document source overrides and generations Peeper uses full synchronization. A document snapshot is the complete text from `didOpen` or the first `didChange` content item. `applyDocumentSnapshot` canonicalizes -the path, stores text in `Cache`, and stores the supplied LSP version. Closing the -document deletes both entries, so later reads fall back to disk. +the path, stores text in `SourceOverrides`, and stores the supplied LSP version. +Closing the document deletes both entries, so later reads fall back to disk. -Every open, change, or close increments `diagGeneration`, including removal of an -overlay. `diagnosticSnapshotLocked` recompiles while state is locked, copies the -selected file list, captures current document versions, and records the current -generation in `diagnosticSnapshot`. +Every open, change, or close increments `diagGeneration`, including removal of a +source override. `diagnosticSnapshotLocked` recompiles while state is locked, +copies the selected file list, captures current document versions, and records +the current generation in `diagnosticSnapshot`. `currentCompiledModule` reuses `LastCtx` only when `lastCtxGeneration` equals the current `diagGeneration`, the module is parsed, and its content hash matches @@ -151,8 +152,8 @@ forms become invalid-parameter errors for request handlers or are ignored by notifications. `pathToURI` normalizes backslashes and path cleaning, preserves UNC authorities, -and emits a `file:` URI. Internal cache, workspace, module, and version keys use -`project.CanonicalPath`; URI conversion is kept at the protocol boundary. +and emits a `file:` URI. Internal source override, workspace, module, and version +keys use `project.CanonicalPath`; URI conversion is kept at the protocol boundary. LSP positions are zero-based and use UTF-16 code units. `offsetAtPosition` walks a line and counts one or two UTF-16 units per decoded rune. `positionAtOffset` does @@ -164,12 +165,25 @@ zero range. ## Workspace index and incremental compilation -`workspaceIndex.rebuild` discovers source files under `RootDir` and adds cached -source files within that root. Cached content wins over disk content in +`workspaceIndex.rebuild` discovers source files under `RootDir` and adds source +overrides within that root. Source overrides win over disk content in `workspaceContent`. Each indexed `workspaceModule` records canonical path, project/import context, content hash, import/export fingerprints, and resolved local import targets. +Each rebuild uses `workspaceFiles` and canonical `project.DiscoverSourceFiles` +to enumerate disk sources, including files added inside previously empty nested +directories. Unchanged files still reuse their content/parse evidence; directory +membership is not inferred from stamps of directories already containing sources. +With no source overrides, the canonical sorted, deduplicated result is returned +directly. + +Filtered file contexts also supply membership checks. Import lookup contexts are +created on demand per project and reused only within that rebuild. +Project lookup maps retain both loaded projects and checked negative results. +Membership comparison includes counts and keys before content reads; a same-count +replacement invalidates graph/components even if its content cannot be read. + Changed or new files are lexed and parsed only for index construction. The index builds a directed import graph and groups modules into weakly connected `workspaceComponent`s. A component stores sorted files and roots. Roots are @@ -208,11 +222,11 @@ If source-project resolution fails, it records a load diagnostic and retains the context. For a configured root, it rebuilds the workspace, computes dirty files, seeds -reusable modules, adds cached sources, and tries the synthetic component entry. +reusable modules, adds source overrides, and tries the synthetic component entry. If that entry produces the requested module, the context is retained and returned. -Otherwise cached sources are added and the requested file is compiled with its -current overlay, if present. Reusable diagnostics are activated, then the context -and modules are retained. +Otherwise source overrides are added and the requested file is compiled with its +current source override, if present. Reusable diagnostics are activated, then the +context and modules are retained. `retainCompiledContext` updates `LastCtx`, `LastMetrics`, and `lastCtxGeneration`. `diagnosticSnapshotLocked` selects a component's files when @@ -255,10 +269,10 @@ rather than maintaining independent AST parent maps for the same operation. ## Hover and rendering -`HandleHover` reads current overlay/disk text, converts the LSP position, resolves -a subject from the current compiled module, renders it, and returns Markdown plus -its source range. `resolveHoverSubject` checks attributes, imports, types, -declarations, selectors, symbols, and expressions in that order. +`HandleHover` reads current source override or disk text, converts the LSP +position, resolves a subject from the current compiled module, renders it, and +returns Markdown plus its source range. `resolveHoverSubject` checks attributes, +imports, types, declarations, selectors, symbols, and expressions in that order. The normalized `hoverSubject` can represent a symbol, expression type, resolved type, declaration, import, or attribute. Type syntax is resolved through @@ -299,8 +313,8 @@ LSP positions. after the cursor, deduplicates names, and sorts results. - Qualified completion returns enum variants or public symbols from an imported module. -- Operation completion builds a sentinel source, compiles it with other cached - overlays, resolves the base type, and offers fields, interface methods, receiver +- Operation completion builds a sentinel source, compiles it with other source + overrides, resolves the base type, and offers fields, interface methods, receiver methods, intrinsics, adaptable local operations, and public imported operations. It creates edits for method-call or pipe syntax and can preserve existing arguments. @@ -317,7 +331,7 @@ ordering by sort text, label, and item kind. - Compiler contexts are never published as diagnostics after their generation is invalidated. - Query handlers do not reuse a compiled module whose generation or content hash - no longer matches the current document overlay. + no longer matches the current document source override. - Full-sync changes replace complete document snapshots; the server does not apply text ranges. - Workspace reuse is constrained by content and import/export fingerprints; a diff --git a/internal/driver/compiler.go b/internal/driver/compiler.go index c58196a7..36494b25 100644 --- a/internal/driver/compiler.go +++ b/internal/driver/compiler.go @@ -21,9 +21,9 @@ func NewCompilerContext(cfg project.Config, diag *diagnostics.DiagnosticBag) *pr return ctx } -// CompileFile compiles the entry file from overlay when non-nil, otherwise it -// reads the source from disk. -func CompileFile(ctx *project.CompilerContext, path string, overlay *string) *module.Module { +// CompileFile compiles the entry file from sourceOverride when non-nil. +// Otherwise it reads the source from disk. +func CompileFile(ctx *project.CompilerContext, path string, sourceOverride *string) *module.Module { if ctx == nil { return nil } @@ -39,7 +39,7 @@ func CompileFile(ctx *project.CompilerContext, path string, overlay *string) *mo return nil } content := "" - if overlay == nil { + if sourceOverride == nil { data, err := os.ReadFile(absPath) if err != nil { loadDiag.Add(diagnostics.NewError("read input file: " + err.Error())) @@ -47,7 +47,7 @@ func CompileFile(ctx *project.CompilerContext, path string, overlay *string) *mo } content = string(data) } else { - content = *overlay + content = *sourceOverride } if module, ok := prelude.ModuleForFile(ctx, absPath, content); ok { module.IsEntry = true @@ -69,7 +69,7 @@ func CompileFile(ctx *project.CompilerContext, path string, overlay *string) *mo } // AddSource registers a virtual/in-memory module in the compiler context. -func AddSource(ctx *project.CompilerContext, path string, content string) { +func AddSource(ctx *project.CompilerContext, path string, sourceOverride string) { if ctx == nil { return } @@ -77,9 +77,9 @@ func AddSource(ctx *project.CompilerContext, path string, content string) { if err != nil { return } - if module, ok := prelude.ModuleForFile(ctx, absPath, content); ok { + if module, ok := prelude.ModuleForFile(ctx, absPath, sourceOverride); ok { ctx.AddModule(module) return } - ctx.AddModule(ctx.NewModuleForFile(absPath, content)) + ctx.AddModule(ctx.NewModuleForFile(absPath, sourceOverride)) } diff --git a/internal/driver/compiler_test.go b/internal/driver/compiler_test.go index 16844f73..ce438353 100644 --- a/internal/driver/compiler_test.go +++ b/internal/driver/compiler_test.go @@ -19,20 +19,20 @@ func TestCompileFileSourceSelection(t *testing.T) { t.Fatalf("write source: %v", err) } empty := "" - nonempty := "fn overlay() -> i32 { return 2; }\n" + nonempty := "fn sourceOverride() -> i32 { return 2; }\n" tests := []struct { - name string - overlay *string - want string + name string + sourceOverride *string + want string }{ {name: "disk", want: disk}, - {name: "empty overlay", overlay: &empty, want: ""}, - {name: "nonempty overlay", overlay: &nonempty, want: nonempty}, + {name: "empty source override", sourceOverride: &empty, want: ""}, + {name: "nonempty source override", sourceOverride: &nonempty, want: nonempty}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: root, Extension: peeper.SourceExt}, diagnostics.NewDiagnosticBag()) - mod := CompileFile(ctx, path, tt.overlay) + mod := CompileFile(ctx, path, tt.sourceOverride) if mod == nil { t.Fatalf("CompileFile returned nil") } diff --git a/internal/lsp/benchmark_test.go b/internal/lsp/benchmark_test.go index 31a344f4..45f69777 100644 --- a/internal/lsp/benchmark_test.go +++ b/internal/lsp/benchmark_test.go @@ -24,51 +24,52 @@ func BenchmarkIncrementalWorkspace(b *testing.B) { fixture := createBenchFixture(b, fixtureName) b.Run(fixtureName, func(b *testing.B) { runBenchCase(b, "cold_compile", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} return fixture.entry }) runBenchCase(b, "warm_no_change_open", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + sourceOverride := fixture.entryImport + fixture.entryBody + state.applyDocumentSnapshot(fixture.entry, &sourceOverride, nil) _, _ = state.recompile(fixture.entry) return fixture.entry }) runBenchCase(b, "function_body_edit", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} _, _ = state.recompile(fixture.entry) updated := "const leafLimit: i32 = 1;\nfn LeafValue() -> i32 { return 2; }\nfn StableValue() -> i32 { return 7; }\n" state.applyDocumentSnapshot(fixture.leaf, &updated, nil) return fixture.entry }) runBenchCase(b, "earlier_function_growth", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} _, _ = state.recompile(fixture.entry) updated := "const leafLimit: i32 = 1;\nfn LeafValue() -> i32 {\n\tlet value = leafLimit;\n\treturn value;\n}\nfn StableValue() -> i32 { return 7; }\n" state.applyDocumentSnapshot(fixture.leaf, &updated, nil) return fixture.entry }) runBenchCase(b, "private_constant_edit", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} _, _ = state.recompile(fixture.entry) updated := "const leafLimit: i32 = 2;\nfn LeafValue() -> i32 { return leafLimit; }\nfn StableValue() -> i32 { return 7; }\n" state.applyDocumentSnapshot(fixture.leaf, &updated, nil) return fixture.entry }) runBenchCase(b, "export_shape_edit", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} _, _ = state.recompile(fixture.entry) updated := "const leafLimit: i32 = 1;\nfn LeafValue() -> i32 { return leafLimit; }\nfn StableValue() -> i32 { return 7; }\nfn AddedValue() -> i32 { return 9; }\n" state.applyDocumentSnapshot(fixture.leaf, &updated, nil) return fixture.entry }) runBenchCase(b, "import_set_edit", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} _, _ = state.recompile(fixture.entry) updated := fixture.entryImport + "import \"bench/extra\";\n" + fixture.entryBody state.applyDocumentSnapshot(fixture.entry, &updated, nil) return fixture.entry }) runBenchCase(b, "unrelated_component_edit", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} _, _ = state.recompile(fixture.entry) _, _ = state.recompile(fixture.unrelated) updated := "fn main() -> i32 { return 2; }\n" @@ -76,11 +77,11 @@ func BenchmarkIncrementalWorkspace(b *testing.B) { return fixture.unrelated }) runBenchCase(b, "multi_main_first_root", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} return fixture.entry }) runBenchCase(b, "multi_main_second_root", fixture, func(state *ServerState) string { - state.Cache = map[string]string{} + state.SourceOverrides = map[string]string{} return fixture.unrelated }) }) diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index b502c625..b6094a8f 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -104,7 +104,7 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte case completionQualified: return qualifiedCompletionItems(ctx, module, parsed.qualifier, parsed.prefix, replacement), nil case completionOperation: - sentinelCtx, sentinelModule := compileCompletionSource(ctx.Config, s.completionOverlays(filePath), filePath, parsed.sentinel) + sentinelCtx, sentinelModule := compileCompletionSource(ctx.Config, s.completionSourceOverrides(filePath), filePath, parsed.sentinel) if sentinelCtx == nil || sentinelModule == nil || sentinelModule.SymbolIndex == nil { return []CompletionItem{}, nil } @@ -130,28 +130,28 @@ func (s *ServerState) HandleCompletion(params CompletionParams) ([]CompletionIte func (s *ServerState) completionSource(filePath string) (string, error) { s.mu.Lock() defer s.mu.Unlock() - return workspaceContent(project.CanonicalPath(filePath), s.Cache) + return workspaceContent(project.CanonicalPath(filePath), s.SourceOverrides) } -func (s *ServerState) completionOverlays(currentFile string) map[string]string { +func (s *ServerState) completionSourceOverrides(currentFile string) map[string]string { s.mu.Lock() defer s.mu.Unlock() currentFile = project.CanonicalPath(currentFile) - overlays := make(map[string]string, len(s.Cache)) - for filePath, content := range s.Cache { + sourceOverrides := make(map[string]string, len(s.SourceOverrides)) + for filePath, sourceOverride := range s.SourceOverrides { if filePath != currentFile { - overlays[filePath] = content + sourceOverrides[filePath] = sourceOverride } } - return overlays + return sourceOverrides } -func compileCompletionSource(cfg project.Config, overlays map[string]string, filePath, content string) (*project.CompilerContext, *module.Module) { +func compileCompletionSource(cfg project.Config, sourceOverrides map[string]string, filePath, sourceOverride string) (*project.CompilerContext, *module.Module) { ctx := compiler.NewCompilerContext(cfg, diagnostics.NewDiagnosticBag()) - for overlayPath, overlayContent := range overlays { - compiler.AddSource(ctx, overlayPath, overlayContent) + for sourceOverridePath, sourceOverrideText := range sourceOverrides { + compiler.AddSource(ctx, sourceOverridePath, sourceOverrideText) } - return ctx, compiler.CompileFile(ctx, filePath, &content) + return ctx, compiler.CompileFile(ctx, filePath, &sourceOverride) } func parseCompletionContext(text string, position Position) parsedCompletionContext { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 400cd767..cf311d33 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -132,9 +132,9 @@ func Run(in io.ReadCloser, out io.Writer) error { continue case "textDocument/didClose": - var params TextDocumentIdentifier + var params DidCloseTextDocumentParams if err := json.Unmarshal(req.Params, ¶ms); err == nil { - filePath, uriErr := uriToPath(string(params.URI)) + filePath, uriErr := uriToPath(string(params.TextDocument.URI)) if uriErr != nil { continue } diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index bfa2b42d..8c674d87 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -6,7 +6,9 @@ import ( "encoding/json" "errors" "io" + "os" "path/filepath" + "reflect" "strconv" "strings" "sync" @@ -763,7 +765,7 @@ func TestHoverRecompilesDirtySnapshot(t *testing.T) { } } -func TestHoverRecompilesWhenLastSnapshotOnlyHasOverlayStub(t *testing.T) { +func TestHoverRecompilesWhenLastSnapshotOnlyHasSourceOverrideStub(t *testing.T) { root := t.TempDir() firstPath := filepath.Join(root, "first"+peeper.SourceExt) secondPath := filepath.Join(root, "second"+peeper.SourceExt) @@ -784,9 +786,9 @@ func TestHoverRecompilesWhenLastSnapshotOnlyHasOverlayStub(t *testing.T) { t.Fatalf("expected compiled module for %s", secondPath) } - // Recompiling second file leaves first file as an overlay stub in LastCtx. + // Recompiling second file leaves first file as a source override stub in LastCtx. if mod, ok := state.LastCtx.ModuleByFile(firstPath); !ok || mod == nil || mod.AST != nil { - t.Fatalf("expected last snapshot to hold first file as unparsed overlay stub") + t.Fatalf("expected last snapshot to hold first file as unparsed source override stub") } hover, err := state.HandleHover(HoverParams{ @@ -1690,6 +1692,98 @@ func TestLSPInitializedPublishesDiagnosticsForUnopenedWorkspaceFiles(t *testing. } } +func TestLSPDidCloseDiscardsSourceOverrideAndVersion(t *testing.T) { + for _, diskBacked := range []bool{false, true} { + name := "unsaved" + if diskBacked { + name = "disk-backed" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeWorkspaceProjectConfig(t, root, "app") + filePath := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + if diskBacked { + writeWorkspaceFile(t, filePath, "fn main() -> i32 { return 0; }\n") + } + rootURI := DocumentURI(pathToURI(root)) + fileURI := DocumentURI(pathToURI(filePath)) + var input bytes.Buffer + for _, message := range []struct { + method string + params any + }{ + {method: "initialize", params: InitializeParams{RootURI: &rootURI}}, + {method: "textDocument/didOpen", params: DidOpenTextDocumentParams{TextDocument: TextDocumentItem{ + URI: fileURI, Version: 7, Text: "fn main() -> i32 { return missing; }\n", + }}}, + {method: "textDocument/didClose", params: map[string]TextDocumentIdentifier{ + "textDocument": {URI: fileURI}, + }}, + } { + params, err := json.Marshal(message.params) + if err != nil { + t.Fatalf("marshal %s: %v", message.method, err) + } + if err := writeMessage(&input, Request{JSONRPC: "2.0", Method: message.method, Params: params}); err != nil { + t.Fatalf("write %s: %v", message.method, err) + } + } + var output bytes.Buffer + if err := Run(io.NopCloser(&input), &output); err != nil { + t.Fatalf("Run: %v", err) + } + published := collectDiagnosticNotifications(t, output.Bytes())[string(fileURI)] + if len(published) != 2 { + t.Fatalf("diagnostic publications = %d, want open and close", len(published)) + } + if published[0].Version == nil || *published[0].Version != 7 || !hasErrorDiagnostic(published[0].Diagnostics) { + t.Fatalf("open diagnostics = %#v, want invalid source override at version 7", published[0]) + } + if published[1].Version != nil || len(published[1].Diagnostics) != 0 { + t.Fatalf("close diagnostics = %#v, want cleared diagnostics without document version", published[1]) + } + }) + } +} + +func TestWorkspaceDiagnosticsFindUnopenedFileAfterClosingDocument(t *testing.T) { + root := t.TempDir() + writeWorkspaceProjectConfig(t, root, "app") + mainFile := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + mainSource := "fn main() {}\n" + writeWorkspaceFile(t, mainFile, mainSource) + emptyDir := filepath.Join(root, peeper.SourceDirName, "nested", "empty") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + t.Fatal(err) + } + state := NewServerState() + state.RootDir = root + state.applyDocumentSnapshot(mainFile, &mainSource, nil) + if snapshots := state.workspaceDiagnosticSnapshots(); len(snapshots) != 1 { + t.Fatalf("initial snapshots = %d, want 1", len(snapshots)) + } + newFile := filepath.Join(emptyDir, "new"+peeper.SourceExt) + writeWorkspaceFile(t, newFile, "fn Broken() -> i32 { return missing; }\n") + state.applyDocumentSnapshot(mainFile, nil, nil) + var output bytes.Buffer + if err := publishWorkspaceDiagnostics(newProtocolWriter(&output), state); err != nil { + t.Fatal(err) + } + published := collectPublishedDiagnostics(t, output.Bytes()) + if got := published[pathToURI(newFile)]; len(got) != 1 || len(got[0]) == 0 { + t.Fatalf("new unopened file diagnostics = %#v, want one nonempty publication", got) + } + fresh := NewServerState() + fresh.RootDir = root + var cleanOutput bytes.Buffer + if err := publishWorkspaceDiagnostics(newProtocolWriter(&cleanOutput), fresh); err != nil { + t.Fatal(err) + } + if expected := collectPublishedDiagnostics(t, cleanOutput.Bytes()); !reflect.DeepEqual(published, expected) { + t.Fatalf("warm diagnostics differ from fresh publication: got %#v, want %#v", published, expected) + } +} + func TestManifestLoadFailuresPublishOnSourceURI(t *testing.T) { tests := []struct { name string diff --git a/internal/lsp/state.go b/internal/lsp/state.go index 9c6c33be..1e301605 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -21,7 +21,7 @@ type ServerState struct { diagWG sync.WaitGroup diagErr error RootDir string - Cache map[string]string + SourceOverrides map[string]string LastCtx *project.CompilerContext LastMetrics project.CompileMetrics lastCtxGeneration uint64 @@ -34,7 +34,7 @@ type ServerState struct { func NewServerState() *ServerState { return &ServerState{ - Cache: make(map[string]string), + SourceOverrides: make(map[string]string), modules: make(map[string]*module.Module), diagVersion: make(map[string]uint64), documentVersions: make(map[string]int), @@ -48,7 +48,7 @@ type diagnosticSnapshot struct { versions map[string]int } -func (s *ServerState) applyDocumentSnapshot(filePath string, text *string, version *int) { +func (s *ServerState) applyDocumentSnapshot(filePath string, sourceOverride *string, version *int) { if s == nil { return } @@ -59,11 +59,11 @@ func (s *ServerState) applyDocumentSnapshot(filePath string, text *string, versi defer s.publishMu.Unlock() s.mu.Lock() defer s.mu.Unlock() - if text == nil { - delete(s.Cache, filePath) + if sourceOverride == nil { + delete(s.SourceOverrides, filePath) delete(s.documentVersions, filePath) } else { - s.Cache[filePath] = *text + s.SourceOverrides[filePath] = *sourceOverride if version != nil { s.documentVersions[filePath] = *version } @@ -113,7 +113,7 @@ func (s *ServerState) workspaceDiagnosticSnapshots() []*diagnosticSnapshot { if s.workspace == nil { s.workspace = newWorkspaceIndex(s.RootDir) } - parsedModules, err := s.workspace.rebuild(s.Cache) + parsedModules, err := s.workspace.rebuild(s.SourceOverrides) if err != nil { return nil } @@ -173,14 +173,14 @@ func (s *ServerState) recompileLocked(entryFile string, parsedModules map[string } // A non-nil map came from a rebuild under this same state lock. if parsedModules == nil { - parsedModules, err = s.workspace.rebuild(s.Cache) + parsedModules, err = s.workspace.rebuild(s.SourceOverrides) } if err == nil { dirtyFiles := s.workspace.dirtyFiles(entryFile, s.modules) ctx.Metrics.AddDirtyFiles(len(dirtyFiles)) deferredDiagnostics = s.seedReusableModules(ctx, dirtyFiles) - for cachedPath, cachedContent := range s.Cache { - compiler.AddSource(ctx, cachedPath, cachedContent) + for filePath, sourceOverride := range s.SourceOverrides { + compiler.AddSource(ctx, filePath, sourceOverride) } if virtualPath, content, ok := s.workspace.syntheticEntry(entryFile); ok { for filePath := range s.workspace.componentFiles(entryFile) { @@ -188,7 +188,7 @@ func (s *ServerState) recompileLocked(entryFile string, parsedModules map[string if !found { continue } - current, err := workspaceContent(filePath, s.Cache) + current, err := workspaceContent(filePath, s.SourceOverrides) if err != nil || current != parsed.content { continue } @@ -223,18 +223,18 @@ func (s *ServerState) recompileLocked(entryFile string, parsedModules map[string } } - for cachedPath, cachedContent := range s.Cache { - if cachedPath == canonicalEntry { + for filePath, sourceOverride := range s.SourceOverrides { + if filePath == canonicalEntry { continue } - compiler.AddSource(ctx, cachedPath, cachedContent) + compiler.AddSource(ctx, filePath, sourceOverride) } - var overlay *string - if content, ok := s.Cache[canonicalEntry]; ok { - overlay = &content + var sourceOverride *string + if content, ok := s.SourceOverrides[canonicalEntry]; ok { + sourceOverride = &content } - mod := compiler.CompileFile(ctx, entryFile, overlay) + mod := compiler.CompileFile(ctx, entryFile, sourceOverride) activateReusableDiagnostics(ctx, deferredDiagnostics) s.retainCompiledContext(ctx) s.captureModules(ctx) @@ -257,16 +257,16 @@ func (s *ServerState) currentCompiledModule(filePath string) (*project.CompilerC canonical := project.CanonicalPath(filePath) if canonical != "" { if mod, ok := s.LastCtx.ModuleByFile(canonical); ok && mod != nil { - // Overlays for other open files register placeholder modules in the - // context before they are parsed. Hover/definition/rename must not - // reuse those stubs even if their content hash matches the buffer. + // Source overrides register placeholder modules for other open files. + // Hover/definition/rename must not reuse those unparsed stubs even + // if their content hash matches the buffer. if mod.AST == nil || mod.Phase < phase.Parsed { return s.recompileLocked(filePath, nil) } // Reuse the last compiled snapshot only when the current buffer text // still matches it. Otherwise hover/definition/rename would keep // reading a frozen AST after edits until some later path recompiles. - if content, err := workspaceContent(canonical, s.Cache); err == nil && mod.ContentHash == fingerprint.Text(content) { + if content, err := workspaceContent(canonical, s.SourceOverrides); err == nil && mod.ContentHash == fingerprint.Text(content) { return s.LastCtx, mod } } @@ -370,7 +370,7 @@ func (s *ServerState) seedReusableModules(ctx *project.CompilerContext, dirtyFil } ctx.Metrics.AddReusedModule() ctx.AddModule(&reused) - if content, err := workspaceContent(filePath, s.Cache); err == nil { + if content, err := workspaceContent(filePath, s.SourceOverrides); err == nil { ctx.Diagnostics.AddSourceContent(reused.FilePath, content) } // Cached artifacts may be ahead of this run's project barrier. Keep their diff --git a/internal/lsp/types.go b/internal/lsp/types.go index fa3faf56..79362f73 100644 --- a/internal/lsp/types.go +++ b/internal/lsp/types.go @@ -86,6 +86,10 @@ type DidChangeTextDocumentParams struct { ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"` } +type DidCloseTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} + type PublishDiagnosticsParams struct { URI DocumentURI `json:"uri"` Version *int `json:"version,omitempty"` diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index e8ccc3cb..be6c8123 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -51,12 +51,11 @@ type workspaceParse struct { } type workspaceIndex struct { - rootDir string - modules map[string]*workspaceModule - components []workspaceComponent - imports *graph.DependencyGraph - directoryStamps map[string]int64 - parsedFiles int + rootDir string + modules map[string]*workspaceModule + components []workspaceComponent + imports *graph.DependencyGraph + parsedFiles int } func newWorkspaceIndex(rootDir string) *workspaceIndex { @@ -66,12 +65,12 @@ func newWorkspaceIndex(rootDir string) *workspaceIndex { } } -func (w *workspaceIndex) rebuild(cache map[string]string) (map[string]workspaceParse, error) { +func (w *workspaceIndex) rebuild(sourceOverrides map[string]string) (map[string]workspaceParse, error) { if w == nil || w.rootDir == "" { return nil, nil } - files, err := w.sourceFiles(cache) + files, err := workspaceFiles(w.rootDir, sourceOverrides) if err != nil { return nil, err } @@ -81,75 +80,77 @@ func (w *workspaceIndex) rebuild(cache map[string]string) (map[string]workspaceP projectName string importPath string } - fileSet := make(map[string]struct{}, len(files)) contexts := make(map[string]workspaceFileContext, len(files)) projectContexts := make(map[[2]string]*project.CompilerContext) - type workspaceProjectLookup struct { - project *manifest.Project - } - projectsByDir := make(map[string]workspaceProjectLookup) - projectsByManifest := make(map[string]workspaceProjectLookup) + contextForProject := func(rootDir, projectName string) *project.CompilerContext { + key := [2]string{rootDir, projectName} + if ctx := projectContexts[key]; ctx != nil { + return ctx + } + ctx := project.NewWithConfig(project.Config{ + RootDir: rootDir, + ProjectName: projectName, + Extension: peeper.SourceExt, + }, diagnostics.NewDiagnosticBag()) + projectContexts[key] = ctx + return ctx + } + projectsByDir := make(map[string]*manifest.Project) + projectsByManifest := make(map[string]*manifest.Project) w.parsedFiles = 0 parsedModules := make(map[string]workspaceParse) for _, filePath := range files { - rootDir := filepath.Dir(filePath) - projectName := "" fileDir := filepath.Dir(filePath) - projectLookup, checked := projectsByDir[fileDir] + rootDir := fileDir + projectName := "" + loadedProject, checked := projectsByDir[fileDir] if !checked { if manifestPath, err := manifest.FindManifestPath(filePath); err == nil { manifestKey := project.CanonicalPath(manifestPath) - projectLookup, checked = projectsByManifest[manifestKey] + loadedProject, checked = projectsByManifest[manifestKey] if !checked { - if loadedProject, loadErr := manifest.LoadProjectFromManifest(manifestPath); loadErr == nil { - projectLookup.project = loadedProject + if configuredProject, loadErr := manifest.LoadProjectFromManifest(manifestPath); loadErr == nil { + loadedProject = configuredProject } - projectsByManifest[manifestKey] = projectLookup + projectsByManifest[manifestKey] = loadedProject } } - projectsByDir[fileDir] = projectLookup + projectsByDir[fileDir] = loadedProject } - if loadedProject := projectLookup.project; loadedProject != nil { + if loadedProject != nil { if !manifest.IsPathWithinSourceDir(loadedProject.RootDir, filePath) { continue } rootDir = loadedProject.RootDir projectName = loadedProject.File.Package.Name } - key := [2]string{rootDir, projectName} - ctx := projectContexts[key] - if ctx == nil { - ctx = project.NewWithConfig(project.Config{ - RootDir: rootDir, - ProjectName: projectName, - Extension: peeper.SourceExt, - }, diagnostics.NewDiagnosticBag()) - projectContexts[key] = ctx - } importPath := "" previous := w.modules[filePath] if previous != nil && previous.rootDir == rootDir && previous.projectName == projectName { importPath = previous.importPath - } else if resolved, err := ctx.ImportPathForFile(project.ModuleOriginLocal, "", filePath); err == nil { - importPath = resolved + } else { + ctx := contextForProject(rootDir, projectName) + if resolved, resolveErr := ctx.ImportPathForFile(project.ModuleOriginLocal, "", filePath); resolveErr == nil { + importPath = resolved + } } - fileSet[filePath] = struct{}{} contexts[filePath] = workspaceFileContext{ rootDir: rootDir, projectName: projectName, importPath: importPath, } } - fileMembershipChanged := len(w.modules) != len(fileSet) - workspaceChanged := fileMembershipChanged + fileMembershipChanged := len(w.modules) != len(contexts) if !fileMembershipChanged { for filePath := range w.modules { - if _, ok := fileSet[filePath]; !ok { + if _, ok := contexts[filePath]; !ok { fileMembershipChanged = true break } } } + // Membership changes invalidate the graph even when replacement content cannot be read. + workspaceChanged := fileMembershipChanged for _, filePath := range files { fileCtx, ok := contexts[filePath] @@ -169,7 +170,7 @@ func (w *workspaceIndex) rebuild(cache map[string]string) (map[string]workspaceP contentHash := module.contentHash contentUnchanged := false var diskInfo os.FileInfo - if _, cached := cache[filePath]; !cached { + if _, hasSourceOverride := sourceOverrides[filePath]; !hasSourceOverride { if info, statErr := os.Stat(filePath); statErr == nil { diskInfo = info if !contextChanged && module.diskStampValid && @@ -181,7 +182,7 @@ func (w *workspaceIndex) rebuild(cache map[string]string) (map[string]workspaceP } if !contentUnchanged { var err error - content, err = workspaceContent(filePath, cache) + content, err = workspaceContent(filePath, sourceOverrides) if err != nil { continue } @@ -220,15 +221,18 @@ func (w *workspaceIndex) rebuild(cache map[string]string) (map[string]workspaceP } module.resolvedLocalImportFiles = module.resolvedLocalImportFiles[:0] - ctx := projectContexts[[2]string{fileCtx.rootDir, fileCtx.projectName}] + if len(module.sourceImportPaths) == 0 { + continue + } + ctx := contextForProject(fileCtx.rootDir, fileCtx.projectName) seen := make(map[string]struct{}) for _, rawPath := range module.sourceImportPaths { - resolved, err := ctx.ResolveImportPath(rawPath) - if err != nil || resolved == nil || resolved.ID.Origin != string(project.ModuleOriginLocal) { + resolved, resolveErr := ctx.ResolveImportPath(rawPath) + if resolveErr != nil || resolved == nil || resolved.ID.Origin != string(project.ModuleOriginLocal) { continue } target := resolved.FilePath - if _, ok := fileSet[target]; !ok { + if _, ok := contexts[target]; !ok { continue } if _, dup := seen[target]; dup { @@ -240,7 +244,7 @@ func (w *workspaceIndex) rebuild(cache map[string]string) (map[string]workspaceP } for filePath := range w.modules { - if _, ok := fileSet[filePath]; ok { + if _, ok := contexts[filePath]; ok { continue } delete(w.modules, filePath) @@ -512,65 +516,21 @@ func buildWorkspaceComponents(modules map[string]*workspaceModule, g *graph.Depe return components } -func (w *workspaceIndex) sourceFiles(cache map[string]string) ([]string, error) { - if len(cache) == 0 && len(w.modules) > 0 && w.directoriesUnchanged() { - files := make([]string, 0, len(w.modules)) - for filePath := range w.modules { - files = append(files, filePath) - } - sort.Strings(files) - return files, nil - } - - files, err := workspaceFiles(w.rootDir, cache) - if err != nil { - return nil, err - } - w.captureDirectoryStamps(files) - return files, nil -} - -func (w *workspaceIndex) directoriesUnchanged() bool { - if len(w.directoryStamps) == 0 { - return false - } - for directory, previous := range w.directoryStamps { - info, err := os.Stat(directory) - if err != nil || !info.IsDir() || info.ModTime().UnixNano() != previous { - return false - } - } - return true -} - -func (w *workspaceIndex) captureDirectoryStamps(files []string) { - stamps := make(map[string]int64) - add := func(directory string) { - info, err := os.Stat(directory) - if err == nil && info.IsDir() { - stamps[directory] = info.ModTime().UnixNano() - } - } - add(w.rootDir) - add(manifest.SourceDir(w.rootDir)) - for _, filePath := range files { - add(filepath.Dir(filePath)) - } - w.directoryStamps = stamps -} - -func workspaceFiles(rootDir string, cache map[string]string) ([]string, error) { +func workspaceFiles(rootDir string, sourceOverrides map[string]string) ([]string, error) { fileSet := make(map[string]struct{}) if rootDir != "" { files, err := project.DiscoverSourceFiles([]string{rootDir}) if err != nil { return nil, err } + if len(sourceOverrides) == 0 { + return files, nil + } for _, path := range files { fileSet[path] = struct{}{} } } - for path := range cache { + for path := range sourceOverrides { if filepath.Ext(path) != peeper.SourceExt { continue } @@ -588,9 +548,9 @@ func workspaceFiles(rootDir string, cache map[string]string) ([]string, error) { return files, nil } -func workspaceContent(filePath string, cache map[string]string) (string, error) { - if content, ok := cache[filePath]; ok { - return content, nil +func workspaceContent(filePath string, sourceOverrides map[string]string) (string, error) { + if sourceOverride, ok := sourceOverrides[filePath]; ok { + return sourceOverride, nil } data, err := os.ReadFile(filePath) if err != nil { diff --git a/internal/lsp/workspace_test.go b/internal/lsp/workspace_test.go index 48f600bf..182d49f8 100644 --- a/internal/lsp/workspace_test.go +++ b/internal/lsp/workspace_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "slices" "strings" "testing" @@ -14,6 +15,7 @@ import ( "compiler/internal/frontend/ast" "compiler/internal/frontend/lexer" "compiler/internal/frontend/parser" + "compiler/internal/graph" "compiler/internal/ir/thir" "compiler/internal/module" "compiler/internal/phase" @@ -485,7 +487,7 @@ func TestWorkspaceReuseReadsPublishedASTSurface(t *testing.T) { updated := "fn helper() { let x = 1; }\n" state.applyDocumentSnapshot(fileUtil, &updated, nil) index := newWorkspaceIndex(root) - if _, err := index.rebuild(state.Cache); err != nil { + if _, err := index.rebuild(state.SourceOverrides); err != nil { t.Fatalf("rebuild workspace index: %v", err) } if dirty := index.dirtyFiles(fileUtil, cached); len(dirty) != 1 { @@ -850,7 +852,7 @@ func TestWorkspaceReusePhasesDowngradesDependentToParsed(t *testing.T) { updated := "fn helper(v: i32) {}\n" state.applyDocumentSnapshot(fileUtil, &updated, nil) index := newWorkspaceIndex(root) - if _, err := index.rebuild(state.Cache); err != nil { + if _, err := index.rebuild(state.SourceOverrides); err != nil { t.Fatalf("rebuild workspace index: %v", err) } @@ -968,7 +970,7 @@ func TestWorkspaceIndexRebuildParsesOnlyChangedFiles(t *testing.T) { state := NewServerState() updated := "fn helper() { let body_only = 1; }\n" state.applyDocumentSnapshot(fileUtil, &updated, nil) - if _, err := index.rebuild(state.Cache); err != nil { + if _, err := index.rebuild(state.SourceOverrides); err != nil { t.Fatalf("incremental rebuild: %v", err) } if got := index.parsedFiles; got != 1 { @@ -1036,6 +1038,126 @@ func TestWorkspaceIndexRebuildDetectsFileMembershipChange(t *testing.T) { } } +func TestWorkspaceIndexRebuildInvalidatesGraphWhenReplacementCannotBeRead(t *testing.T) { + root := t.TempDir() + writeWorkspaceProjectConfig(t, root, "app") + mainFile := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + first := filepath.Join(root, peeper.SourceDirName, "first"+peeper.SourceExt) + second := filepath.Join(root, peeper.SourceDirName, "second"+peeper.SourceExt) + writeWorkspaceFile(t, mainFile, "import \"app/first\";\nfn main() {}\n") + writeWorkspaceFile(t, first, "fn First() {}\n") + index := newWorkspaceIndex(root) + if _, err := index.rebuild(nil); err != nil { + t.Fatalf("initial rebuild: %v", err) + } + mainPath := project.CanonicalPath(mainFile) + firstPath := project.CanonicalPath(first) + secondPath := project.CanonicalPath(second) + if got := index.imports.TransitiveDependents([]graph.NodeID{graph.NodeID(firstPath)}); !slices.Equal(got, []graph.NodeID{graph.NodeID(mainPath)}) { + t.Fatalf("initial importers = %v, want main", got) + } + if err := os.Symlink(filepath.Join(root, "missing-source"), second); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("source symlink unavailable: %v", err) + } + t.Fatal(err) + } + if err := os.Remove(first); err != nil { + t.Fatal(err) + } + if _, err := index.rebuild(nil); err != nil { + t.Fatalf("rebuild after unreadable replacement: %v", err) + } + if len(index.modules) != 2 || index.modules[firstPath] != nil || index.modules[secondPath] == nil || index.parsedFiles != 0 { + t.Fatalf("replacement membership: modules=%v parsed=%d, want main/second and no new parse", index.modules, index.parsedFiles) + } + if got := index.modules[mainPath].resolvedLocalImportFiles; len(got) != 0 { + t.Fatalf("current import targets = %v, want none", got) + } + if got := index.imports.TransitiveDependents([]graph.NodeID{graph.NodeID(firstPath)}); len(got) != 0 { + t.Errorf("removed target still has graph importers: %v", got) + } + fresh := newWorkspaceIndex(root) + if _, err := fresh.rebuild(nil); err != nil { + t.Fatalf("fresh rebuild: %v", err) + } + if !reflect.DeepEqual(index.components, fresh.components) { + t.Errorf("components after read failure = %#v, fresh = %#v", index.components, fresh.components) + } +} + +func TestWorkspaceIndexRebuildFindsFilesInExistingEmptyDirectory(t *testing.T) { + for _, withSourceOverride := range []bool{false, true} { + t.Run(fmt.Sprintf("sourceOverride=%t", withSourceOverride), func(t *testing.T) { + root := t.TempDir() + writeWorkspaceProjectConfig(t, root, "app") + mainFile := filepath.Join(root, peeper.SourceDirName, peeper.MainFileName) + mainSource := "import \"app/nested/empty/new\";\nfn main() {}\n" + writeWorkspaceFile(t, mainFile, mainSource) + emptyDir := filepath.Join(root, peeper.SourceDirName, "nested", "empty") + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + t.Fatal(err) + } + var sourceOverrides map[string]string + if withSourceOverride { + sourceOverrides = map[string]string{project.CanonicalPath(mainFile): mainSource} + } + index := newWorkspaceIndex(root) + if _, err := index.rebuild(sourceOverrides); err != nil { + t.Fatalf("initial rebuild: %v", err) + } + newFile := filepath.Join(emptyDir, "new"+peeper.SourceExt) + renamedFile := filepath.Join(emptyDir, "renamed"+peeper.SourceExt) + for _, step := range []struct { + name, from, to string + parsedFiles int + }{ + {name: "add", to: newFile, parsedFiles: 1}, + {name: "rename", from: newFile, to: renamedFile, parsedFiles: 1}, + {name: "remove", from: renamedFile}, + {name: "re-add", to: newFile, parsedFiles: 1}, + } { + switch { + case step.from == "": + writeWorkspaceFile(t, step.to, "fn Helper() {}\n") + case step.to == "": + if err := os.Remove(step.from); err != nil { + t.Fatal(err) + } + default: + if err := os.Rename(step.from, step.to); err != nil { + t.Fatal(err) + } + } + if _, err := index.rebuild(sourceOverrides); err != nil { + t.Fatalf("%s rebuild: %v", step.name, err) + } + fresh := newWorkspaceIndex(root) + if _, err := fresh.rebuild(sourceOverrides); err != nil { + t.Fatalf("%s fresh rebuild: %v", step.name, err) + } + if len(index.modules) != len(fresh.modules) { + t.Fatalf("%s modules = %d, fresh = %d", step.name, len(index.modules), len(fresh.modules)) + } + for path, expected := range fresh.modules { + actual := index.modules[path] + if actual == nil || actual.importPath != expected.importPath || actual.contentHash != expected.contentHash || + !slices.Equal(actual.sourceImportPaths, expected.sourceImportPaths) || + !slices.Equal(actual.resolvedLocalImportFiles, expected.resolvedLocalImportFiles) { + t.Fatalf("%s module %s differs from fresh index: got %#v, want %#v", step.name, path, actual, expected) + } + } + if !reflect.DeepEqual(index.components, fresh.components) { + t.Fatalf("%s components = %#v, fresh = %#v", step.name, index.components, fresh.components) + } + if index.parsedFiles != step.parsedFiles { + t.Fatalf("%s parsed files = %d, want %d", step.name, index.parsedFiles, step.parsedFiles) + } + } + }) + } +} + func TestWorkspaceIndexKeepsProjectImportContextsSeparate(t *testing.T) { root := t.TempDir() for _, projectName := range []string{"first", "second"} { @@ -1048,19 +1170,37 @@ func TestWorkspaceIndexKeepsProjectImportContextsSeparate(t *testing.T) { } index := newWorkspaceIndex(root) - if _, err := index.rebuild(nil); err != nil { - t.Fatal(err) - } - for _, projectName := range []string{"first", "second"} { - projectRoot := filepath.Join(root, projectName) - mainPath := project.CanonicalPath(filepath.Join(projectRoot, peeper.SourceDirName, peeper.MainFileName)) - utilPath := project.CanonicalPath(filepath.Join(projectRoot, peeper.SourceDirName, "util"+peeper.SourceExt)) - main := index.modules[mainPath] - if main == nil || main.importPath != projectName+"/main" || - !slices.Equal(main.resolvedLocalImportFiles, []string{utilPath}) { - t.Fatalf("%s project imports = %#v, want %s/util -> %s", projectName, main, projectName, utilPath) + firstUtil := filepath.Join(root, "first", peeper.SourceDirName, "util"+peeper.SourceExt) + assertProjectImports := func(label string, parsedFiles int, firstUtilPresent bool) { + t.Helper() + if _, err := index.rebuild(nil); err != nil { + t.Fatalf("%s rebuild: %v", label, err) + } + if index.parsedFiles != parsedFiles { + t.Fatalf("%s parsed files = %d, want %d", label, index.parsedFiles, parsedFiles) } + for _, projectName := range []string{"first", "second"} { + projectRoot := filepath.Join(root, projectName) + mainPath := project.CanonicalPath(filepath.Join(projectRoot, peeper.SourceDirName, peeper.MainFileName)) + utilPath := project.CanonicalPath(filepath.Join(projectRoot, peeper.SourceDirName, "util"+peeper.SourceExt)) + wantTargets := []string{utilPath} + if projectName == "first" && !firstUtilPresent { + wantTargets = nil + } + main := index.modules[mainPath] + if main == nil || main.importPath != projectName+"/main" || + !slices.Equal(main.resolvedLocalImportFiles, wantTargets) { + t.Fatalf("%s %s project imports = %#v, want %v", label, projectName, main, wantTargets) + } + } + } + assertProjectImports("initial", 4, true) + if err := os.Remove(firstUtil); err != nil { + t.Fatal(err) } + assertProjectImports("remove", 0, false) + writeWorkspaceFile(t, firstUtil, "fn helper() {}\n") + assertProjectImports("restore", 1, true) } func TestWorkspaceIndexUsesNearestNestedProjectAndRefreshesManifest(t *testing.T) { @@ -1347,7 +1487,7 @@ func TestServerStateKeepsWorkspaceIndexAcrossRecompile(t *testing.T) { } } -func TestRecompileUsesEmptyDocumentOverlay(t *testing.T) { +func TestRecompileUsesEmptySourceOverride(t *testing.T) { root := t.TempDir() writeWorkspaceProjectConfig(t, root, "app") entry := filepath.Join(root, peeper.SourceDirName, "main"+peeper.SourceExt) @@ -1360,10 +1500,10 @@ func TestRecompileUsesEmptyDocumentOverlay(t *testing.T) { state.applyDocumentSnapshot(entry, &empty, nil) _, mod := state.recompile(entry) if mod == nil { - t.Fatalf("empty overlay compile returned nil module") + t.Fatalf("empty source override compile returned nil module") } if mod.ContentHash != fingerprint.Text("") { - t.Fatalf("empty overlay hash = %q, want empty source hash", mod.ContentHash) + t.Fatalf("empty source override hash = %q, want empty source hash", mod.ContentHash) } state.applyDocumentSnapshot(entry, nil, nil) @@ -1372,7 +1512,7 @@ func TestRecompileUsesEmptyDocumentOverlay(t *testing.T) { t.Fatalf("disk compile returned nil module") } if mod.ContentHash != fingerprint.Text(disk) { - t.Fatalf("closed overlay hash = %q, want disk source hash", mod.ContentHash) + t.Fatalf("removed source override hash = %q, want disk source hash", mod.ContentHash) } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 8582e71e..bb89908e 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -35,7 +35,8 @@ func Run(ctx *project.CompilerContext, entry *module.Module) error { } entry.IsEntry = true - // Explicit entry content must replace any overlay stub registered for same ID. + // Explicit entry content must replace any source override stub registered + // for the same ID. ctx.AddModule(entry) ctx.CompletedProjectPhase = phase.Load diag := ctx.Diagnostics diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 88f26300..90890248 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -1348,8 +1348,8 @@ func TestRequireScheduledModulesAtLeastReportsStoppedPhase(t *testing.T) { if err := requireScheduledModulesAtLeast([]*module.Module{{ID: moduleid.ID{ImportPath: "local:main"}, Phase: phase.Backend}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { t.Fatalf("completed module rejected: %v", err) } - if err := requireScheduledModulesAtLeast([]*module.Module{{ID: moduleid.ID{ImportPath: "overlay:stub"}, Phase: phase.None}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { - t.Fatalf("unscheduled overlay rejected: %v", err) + if err := requireScheduledModulesAtLeast([]*module.Module{{ID: moduleid.ID{ImportPath: "sourceOverride:stub"}, Phase: phase.None}}, map[moduleid.ID]string{moduleid.ID{ImportPath: "local:main"}: ""}, phase.Backend); err != nil { + t.Fatalf("unscheduled source override rejected: %v", err) } } diff --git a/internal/prelude/prelude.go b/internal/prelude/prelude.go index 6fdce00b..6e104777 100644 --- a/internal/prelude/prelude.go +++ b/internal/prelude/prelude.go @@ -45,7 +45,7 @@ func globalPreludePath(ctx *project.CompilerContext) (string, bool) { } // ModuleForFile returns the canonical prelude module identity when a file path -// points at the auto-loaded global prelude source. Direct-open and overlay +// points at the auto-loaded global prelude source. Direct-open and source override // paths must reuse this exact identity so the same file does not appear twice // in compiler and LSP caches. func ModuleForFile(ctx *project.CompilerContext, filePath, content string) (*module.Module, bool) { From 98f37e8072ce7a8509fa2041d3e064729d73f6c8 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sun, 4 Oct 2026 20:36:55 +0600 Subject: [PATCH 2/3] Use sourceText for source content values --- internal/driver/compiler.go | 16 ++++++++-------- internal/driver/compiler_test.go | 12 ++++++------ internal/lsp/benchmark_test.go | 6 +++--- internal/lsp/completion.go | 14 +++++++------- internal/lsp/state.go | 22 +++++++++++----------- internal/lsp/workspace.go | 6 +++--- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/internal/driver/compiler.go b/internal/driver/compiler.go index 36494b25..106e6f09 100644 --- a/internal/driver/compiler.go +++ b/internal/driver/compiler.go @@ -21,9 +21,9 @@ func NewCompilerContext(cfg project.Config, diag *diagnostics.DiagnosticBag) *pr return ctx } -// CompileFile compiles the entry file from sourceOverride when non-nil. +// CompileFile compiles the entry file from sourceText when non-nil. // Otherwise it reads the source from disk. -func CompileFile(ctx *project.CompilerContext, path string, sourceOverride *string) *module.Module { +func CompileFile(ctx *project.CompilerContext, path string, sourceText *string) *module.Module { if ctx == nil { return nil } @@ -39,7 +39,7 @@ func CompileFile(ctx *project.CompilerContext, path string, sourceOverride *stri return nil } content := "" - if sourceOverride == nil { + if sourceText == nil { data, err := os.ReadFile(absPath) if err != nil { loadDiag.Add(diagnostics.NewError("read input file: " + err.Error())) @@ -47,7 +47,7 @@ func CompileFile(ctx *project.CompilerContext, path string, sourceOverride *stri } content = string(data) } else { - content = *sourceOverride + content = *sourceText } if module, ok := prelude.ModuleForFile(ctx, absPath, content); ok { module.IsEntry = true @@ -69,7 +69,7 @@ func CompileFile(ctx *project.CompilerContext, path string, sourceOverride *stri } // AddSource registers a virtual/in-memory module in the compiler context. -func AddSource(ctx *project.CompilerContext, path string, sourceOverride string) { +func AddSource(ctx *project.CompilerContext, path string, sourceText string) { if ctx == nil { return } @@ -77,9 +77,9 @@ func AddSource(ctx *project.CompilerContext, path string, sourceOverride string) if err != nil { return } - if module, ok := prelude.ModuleForFile(ctx, absPath, sourceOverride); ok { + if module, ok := prelude.ModuleForFile(ctx, absPath, sourceText); ok { ctx.AddModule(module) return } - ctx.AddModule(ctx.NewModuleForFile(absPath, sourceOverride)) -} + ctx.AddModule(ctx.NewModuleForFile(absPath, sourceText)) +} \ No newline at end of file diff --git a/internal/driver/compiler_test.go b/internal/driver/compiler_test.go index ce438353..560eea4b 100644 --- a/internal/driver/compiler_test.go +++ b/internal/driver/compiler_test.go @@ -19,20 +19,20 @@ func TestCompileFileSourceSelection(t *testing.T) { t.Fatalf("write source: %v", err) } empty := "" - nonempty := "fn sourceOverride() -> i32 { return 2; }\n" + nonempty := "fn sourceText() -> i32 { return 2; }\n" tests := []struct { name string - sourceOverride *string + sourceText *string want string }{ {name: "disk", want: disk}, - {name: "empty source override", sourceOverride: &empty, want: ""}, - {name: "nonempty source override", sourceOverride: &nonempty, want: nonempty}, + {name: "empty source override", sourceText: &empty, want: ""}, + {name: "nonempty source override", sourceText: &nonempty, want: nonempty}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := project.NewWithConfig(project.Config{RootDir: root, Extension: peeper.SourceExt}, diagnostics.NewDiagnosticBag()) - mod := CompileFile(ctx, path, tt.sourceOverride) + mod := CompileFile(ctx, path, tt.sourceText) if mod == nil { t.Fatalf("CompileFile returned nil") } @@ -41,4 +41,4 @@ func TestCompileFileSourceSelection(t *testing.T) { } }) } -} +} \ No newline at end of file diff --git a/internal/lsp/benchmark_test.go b/internal/lsp/benchmark_test.go index 45f69777..4ddbffd7 100644 --- a/internal/lsp/benchmark_test.go +++ b/internal/lsp/benchmark_test.go @@ -28,8 +28,8 @@ func BenchmarkIncrementalWorkspace(b *testing.B) { return fixture.entry }) runBenchCase(b, "warm_no_change_open", fixture, func(state *ServerState) string { - sourceOverride := fixture.entryImport + fixture.entryBody - state.applyDocumentSnapshot(fixture.entry, &sourceOverride, nil) + sourceText := fixture.entryImport + fixture.entryBody + state.applyDocumentSnapshot(fixture.entry, &sourceText, nil) _, _ = state.recompile(fixture.entry) return fixture.entry }) @@ -171,4 +171,4 @@ func writeBenchWorkspaceFile(tb testing.TB, path, content string) { if err := os.WriteFile(path, []byte(content), 0o644); err != nil { tb.Fatalf("write %s: %v", path, err) } -} +} \ No newline at end of file diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index b6094a8f..5ddce090 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -138,20 +138,20 @@ func (s *ServerState) completionSourceOverrides(currentFile string) map[string]s defer s.mu.Unlock() currentFile = project.CanonicalPath(currentFile) sourceOverrides := make(map[string]string, len(s.SourceOverrides)) - for filePath, sourceOverride := range s.SourceOverrides { + for filePath, sourceText := range s.SourceOverrides { if filePath != currentFile { - sourceOverrides[filePath] = sourceOverride + sourceOverrides[filePath] = sourceText } } return sourceOverrides } -func compileCompletionSource(cfg project.Config, sourceOverrides map[string]string, filePath, sourceOverride string) (*project.CompilerContext, *module.Module) { +func compileCompletionSource(cfg project.Config, sourceOverrides map[string]string, filePath, sourceText string) (*project.CompilerContext, *module.Module) { ctx := compiler.NewCompilerContext(cfg, diagnostics.NewDiagnosticBag()) - for sourceOverridePath, sourceOverrideText := range sourceOverrides { - compiler.AddSource(ctx, sourceOverridePath, sourceOverrideText) + for sourcePath, sourceText := range sourceOverrides { + compiler.AddSource(ctx, sourcePath, sourceText) } - return ctx, compiler.CompileFile(ctx, filePath, &sourceOverride) + return ctx, compiler.CompileFile(ctx, filePath, &sourceText) } func parseCompletionContext(text string, position Position) parsedCompletionContext { @@ -854,4 +854,4 @@ func sortCompletionItems(items []CompletionItem) []CompletionItem { return a.Kind - b.Kind }) return items -} +} \ No newline at end of file diff --git a/internal/lsp/state.go b/internal/lsp/state.go index 1e301605..57ec0ebd 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -48,7 +48,7 @@ type diagnosticSnapshot struct { versions map[string]int } -func (s *ServerState) applyDocumentSnapshot(filePath string, sourceOverride *string, version *int) { +func (s *ServerState) applyDocumentSnapshot(filePath string, sourceText *string, version *int) { if s == nil { return } @@ -59,11 +59,11 @@ func (s *ServerState) applyDocumentSnapshot(filePath string, sourceOverride *str defer s.publishMu.Unlock() s.mu.Lock() defer s.mu.Unlock() - if sourceOverride == nil { + if sourceText == nil { delete(s.SourceOverrides, filePath) delete(s.documentVersions, filePath) } else { - s.SourceOverrides[filePath] = *sourceOverride + s.SourceOverrides[filePath] = *sourceText if version != nil { s.documentVersions[filePath] = *version } @@ -179,8 +179,8 @@ func (s *ServerState) recompileLocked(entryFile string, parsedModules map[string dirtyFiles := s.workspace.dirtyFiles(entryFile, s.modules) ctx.Metrics.AddDirtyFiles(len(dirtyFiles)) deferredDiagnostics = s.seedReusableModules(ctx, dirtyFiles) - for filePath, sourceOverride := range s.SourceOverrides { - compiler.AddSource(ctx, filePath, sourceOverride) + for filePath, sourceText := range s.SourceOverrides { + compiler.AddSource(ctx, filePath, sourceText) } if virtualPath, content, ok := s.workspace.syntheticEntry(entryFile); ok { for filePath := range s.workspace.componentFiles(entryFile) { @@ -223,18 +223,18 @@ func (s *ServerState) recompileLocked(entryFile string, parsedModules map[string } } - for filePath, sourceOverride := range s.SourceOverrides { + for filePath, sourceText := range s.SourceOverrides { if filePath == canonicalEntry { continue } - compiler.AddSource(ctx, filePath, sourceOverride) + compiler.AddSource(ctx, filePath, sourceText) } - var sourceOverride *string + var sourceText *string if content, ok := s.SourceOverrides[canonicalEntry]; ok { - sourceOverride = &content + sourceText = &content } - mod := compiler.CompileFile(ctx, entryFile, sourceOverride) + mod := compiler.CompileFile(ctx, entryFile, sourceText) activateReusableDiagnostics(ctx, deferredDiagnostics) s.retainCompiledContext(ctx) s.captureModules(ctx) @@ -422,4 +422,4 @@ func (s *ServerState) captureModules(ctx *project.CompilerContext) { } s.modules[module.FilePath] = module } -} +} \ No newline at end of file diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index be6c8123..a6f1fcd3 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -549,12 +549,12 @@ func workspaceFiles(rootDir string, sourceOverrides map[string]string) ([]string } func workspaceContent(filePath string, sourceOverrides map[string]string) (string, error) { - if sourceOverride, ok := sourceOverrides[filePath]; ok { - return sourceOverride, nil + if sourceText, ok := sourceOverrides[filePath]; ok { + return sourceText, nil } data, err := os.ReadFile(filePath) if err != nil { return "", err } return string(data), nil -} +} \ No newline at end of file From b3e160b106692858fa0ca9b205f46b5b9bd4496c Mon Sep 17 00:00:00 2001 From: itsfuad Date: Sun, 4 Oct 2026 20:40:05 +0600 Subject: [PATCH 3/3] Preserve Go file formatting --- internal/driver/compiler.go | 2 +- internal/driver/compiler_test.go | 6 +++--- internal/lsp/benchmark_test.go | 2 +- internal/lsp/completion.go | 2 +- internal/lsp/state.go | 2 +- internal/lsp/workspace.go | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/driver/compiler.go b/internal/driver/compiler.go index 106e6f09..4a7c4bb7 100644 --- a/internal/driver/compiler.go +++ b/internal/driver/compiler.go @@ -82,4 +82,4 @@ func AddSource(ctx *project.CompilerContext, path string, sourceText string) { return } ctx.AddModule(ctx.NewModuleForFile(absPath, sourceText)) -} \ No newline at end of file +} diff --git a/internal/driver/compiler_test.go b/internal/driver/compiler_test.go index 560eea4b..fc0715fe 100644 --- a/internal/driver/compiler_test.go +++ b/internal/driver/compiler_test.go @@ -21,9 +21,9 @@ func TestCompileFileSourceSelection(t *testing.T) { empty := "" nonempty := "fn sourceText() -> i32 { return 2; }\n" tests := []struct { - name string + name string sourceText *string - want string + want string }{ {name: "disk", want: disk}, {name: "empty source override", sourceText: &empty, want: ""}, @@ -41,4 +41,4 @@ func TestCompileFileSourceSelection(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/lsp/benchmark_test.go b/internal/lsp/benchmark_test.go index 4ddbffd7..88181b61 100644 --- a/internal/lsp/benchmark_test.go +++ b/internal/lsp/benchmark_test.go @@ -171,4 +171,4 @@ func writeBenchWorkspaceFile(tb testing.TB, path, content string) { if err := os.WriteFile(path, []byte(content), 0o644); err != nil { tb.Fatalf("write %s: %v", path, err) } -} \ No newline at end of file +} diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index 5ddce090..e73f3c10 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -854,4 +854,4 @@ func sortCompletionItems(items []CompletionItem) []CompletionItem { return a.Kind - b.Kind }) return items -} \ No newline at end of file +} diff --git a/internal/lsp/state.go b/internal/lsp/state.go index 57ec0ebd..4885c53e 100644 --- a/internal/lsp/state.go +++ b/internal/lsp/state.go @@ -422,4 +422,4 @@ func (s *ServerState) captureModules(ctx *project.CompilerContext) { } s.modules[module.FilePath] = module } -} \ No newline at end of file +} diff --git a/internal/lsp/workspace.go b/internal/lsp/workspace.go index a6f1fcd3..7ff15d0f 100644 --- a/internal/lsp/workspace.go +++ b/internal/lsp/workspace.go @@ -557,4 +557,4 @@ func workspaceContent(filePath string, sourceOverrides map[string]string) (strin return "", err } return string(data), nil -} \ No newline at end of file +}