15 files changed,
429 insertions(+),
85 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-09-10 13:33:34 +0300
Authored at:
2026-09-08 15:18:37 +0300
Change ID:
rnrotwollprovssurotuxlxnoqqmokuz
Parent:
8909472
jump to
M
internal/cli/cmd_lsp.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "os" 6 7 7 8 "github.com/urfave/cli/v3" ··· 14 15 if err != nil { 15 16 return err 16 17 } 17 - 18 18 server, err := lsp.NewServer(c.version, configPath) 19 19 if err != nil { 20 20 return err 21 21 } 22 - return server.Run(ctx, os.Stdin, os.Stdout) 22 + if err := server.Run(ctx, os.Stdin, os.Stdout); err != nil { 23 + if lee, ok := errors.AsType[*lsp.ExitError](err); ok { 24 + os.Exit(lee.Code) 25 + } 26 + return err 27 + } 28 + return nil 23 29 }
M
internal/lsp/diagnostics.go
··· 9 9 "go.lsp.dev/uri" 10 10 11 11 "olexsmir.xyz/clerk/internal/linter" 12 - "olexsmir.xyz/clerk/journal/token" 12 + "olexsmir.xyz/clerk/internal/lsp/lsputil" 13 13 ) 14 14 15 15 const diagDebounce = 200 * time.Millisecond ··· 61 61 } 62 62 63 63 var finds []linter.Find 64 + lines := make(map[string]*lsputil.LineIndex) 64 65 paths := make(map[string]bool) 65 66 for _, u := range dirtyURIs { 66 67 a := s.analysisFor(u) 67 68 if a == nil { 68 69 continue 70 + } 71 + var idx map[string]*lsputil.LineIndex 72 + if st, ok := s.getDocState(u); ok && st.cache != nil { 73 + idx = st.cache.fileIdx 74 + } else { 75 + idx = buildFileIdx(a) 69 76 } 70 77 for _, pf := range a.Files { 78 + lines[pf.Path] = idx[pf.Path] 71 79 paths[pf.Path] = true 72 80 } 73 81 finds = append(finds, lint.Run(a)...) ··· 78 86 } 79 87 80 88 s.assignSeverities(finds) 81 - diagsByFile := s.groupFindsByFile(dedupFinds(finds)) 89 + diagsByFile := s.groupFindsByFile(dedupFinds(finds), lines) 82 90 for fpath := range paths { 83 91 if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ 84 92 URI: uri.File(fpath), ··· 91 99 s.log.Debug("diagnostics published", "files", len(paths), "findings", len(finds)) 92 100 } 93 101 94 -func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic { 102 +func (s *server) groupFindsByFile(finds []linter.Find, lines map[string]*lsputil.LineIndex) map[string][]protocol.Diagnostic { 95 103 // count per file to pre-size the diagnostic slices: append growth on ~10k 96 104 // findings is the dominant allocation in the diagnostics path 97 105 counts := make(map[string]int, len(finds)) ··· 109 117 if file == "" { 110 118 continue 111 119 } 112 - diags[file] = append(diags[file], s.findToDiagnostic(find)) 120 + diags[file] = append(diags[file], s.findToDiagnostic(find, lines[file])) 113 121 } 114 122 return diags 115 123 } 116 124 117 -func (s *server) findToDiagnostic(find linter.Find) protocol.Diagnostic { 125 +func (s *server) findToDiagnostic(find linter.Find, lines *lsputil.LineIndex) protocol.Diagnostic { 118 126 return protocol.Diagnostic{ 119 - Range: spanToRange(find.Span), 127 + Range: lines.SpanRange(find.Span), 120 128 Severity: severityToLSP(find.Severity), 121 129 Message: protocol.String(find.Message), 122 130 Source: protocol.NewOptional(s.name), ··· 142 150 file string 143 151 line, col int 144 152 code linter.RuleID 145 -} 146 - 147 -func spanToRange(span token.Span) protocol.Range { 148 - return protocol.Range{ 149 - Start: protocol.Position{ 150 - Line: max(0, uint32(span.Start.Line-1)), 151 - Character: max(0, uint32(span.Start.Col-1)), 152 - }, 153 - End: protocol.Position{ 154 - Line: max(0, uint32(span.End.Line-1)), 155 - Character: uint32(max(0, span.End.Col-1)), 156 - }, 157 - } 158 153 } 159 154 160 155 func (s *server) assignSeverities(finds []linter.Find) {
M
internal/lsp/diagnostics_test.go
··· 17 17 b.Fatal(err) 18 18 } 19 19 20 - // Per-edit cost: a fresh loader skips the parse cache, so each iteration re-parses, then lints and groups findings. 20 + // Per-edit cost: a fresh loader skips the parse cache, so each iteration re-parses, 21 + // builds the per-file indices with the analysis, then lints and groups findings. 21 22 b.ReportAllocs() 22 23 b.ResetTimer() 23 24 for b.Loop() { 24 25 an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", []byte(content))) 26 + lines := buildFileIdx(an) 27 + 25 28 finds := dedupFinds(lint.Run(an)) 26 29 srv.server.assignSeverities(finds) 27 - _ = srv.server.groupFindsByFile(finds) 30 + _ = srv.server.groupFindsByFile(finds, lines) 28 31 } 29 32 }
A
internal/lsp/lifecycle.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "runtime" 8 + "syscall" 9 + "time" 10 + 11 + "go.lsp.dev/jsonrpc2" 12 + "go.lsp.dev/protocol" 13 +) 14 + 15 +var ( 16 + errServerNotInitialized = jsonrpc2.NewError(jsonrpc2.ServerNotInitialized, "server not initialized") 17 + errInitializeOnce = jsonrpc2.NewError(jsonrpc2.InvalidRequest, "initialize may only be sent once") 18 +) 19 + 20 +type ExitError struct{ Code int } 21 + 22 +func (e *ExitError) Error() string { 23 + return fmt.Sprintf("lsp exit code %d", e.Code) 24 +} 25 + 26 +type serverState int 27 + 28 +const ( 29 + stateNew serverState = iota // before the initialize request has been answered 30 + stateInitialized // an InitializeResult was sent 31 + stateShutdownRequested // shutdown was received 32 + stateExited // exit arrived without a prior shutdown 33 + stateExitedAfterShutdown // exit arrived after shutdown 34 +) 35 + 36 +func (st serverState) ExitCode() int { 37 + switch st { 38 + case stateExitedAfterShutdown: 39 + return 0 40 + default: 41 + return 1 42 + } 43 +} 44 + 45 +// lifecycle gates the connection on the initialize request: before it, only 46 +// initialize and exit are served; everything else answers ServerNotInitialized 47 +// and notifications are dropped. Every later message is served — like gopls, 48 +// the server trusts its client, so shutdown and exit leave subsequent requests 49 +// to fail naturally. The handlers themselves enforce initialize-once and the 50 +// exit code. 51 +func (s *server) lifecycle(next jsonrpc2.Handler) jsonrpc2.Handler { 52 + return func(ctx context.Context, req *jsonrpc2.Request) (any, error) { 53 + s.stateMu.Lock() 54 + st := s.state 55 + s.stateMu.Unlock() 56 + if st != stateNew || req.Method() == protocol.MethodInitialize || req.Method() == protocol.MethodExit { 57 + return next(ctx, req) 58 + } 59 + return rejectCall(errServerNotInitialized, req) 60 + } 61 +} 62 + 63 +// rejectCall answers a call with err and drops a notification. 64 +func rejectCall(err error, req *jsonrpc2.Request) (any, error) { 65 + if req.IsCall() { 66 + return nil, err 67 + } 68 + return nil, nil // notification dropped 69 +} 70 + 71 +// parentPollInterval is how often watchParent probes the parent process. 72 +const parentPollInterval = 2 * time.Second 73 + 74 +// watchParent exits the server when the parent process named in the initialize 75 +// params dies, as the spec's processId semantics require. Liveness is probed 76 +// with signal 0, which is unix-only; elsewhere the stream EOF that follows a 77 +// parent death covers it. The first probe runs immediately, so a parent that 78 +// is already dead is detected at once. Exit runs the same lifecycle path as 79 +// the exit notification, so the exit code follows the shutdown phase. 80 +func (s *server) watchParent(pid int32) { 81 + if pid <= 0 || runtime.GOOS == "windows" || runtime.GOOS == "plan9" { 82 + return 83 + } 84 + go func() { 85 + t := time.NewTicker(parentPollInterval) 86 + defer t.Stop() 87 + for { 88 + // Only ESRCH proves death; nil (alive) and other errors leave it open. 89 + if err := syscall.Kill(int(pid), 0); err == nil || !errors.Is(err, syscall.ESRCH) { 90 + <-t.C 91 + continue 92 + } 93 + s.Exit(context.Background()) 94 + return 95 + } 96 + }() 97 +}
A
internal/lsp/lifecycle_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "errors" 5 + "io" 6 + "os/exec" 7 + "testing" 8 + "time" 9 + 10 + "go.lsp.dev/jsonrpc2" 11 + "go.lsp.dev/protocol" 12 + "go.lsp.dev/uri" 13 +) 14 + 15 +func TestLifecycle_BeforeInitialize(t *testing.T) { 16 + lct := startLifecycleTest(t) 17 + // requests answer ServerNotInitialized 18 + if _, err := lct.srv.Hover(t.Context(), &protocol.HoverParams{}); !codeEq(err, jsonrpc2.ServerNotInitialized) { 19 + t.Fatalf("request before initialize: want -32002, got %v", err) 20 + } 21 + // notifications are dropped without error 22 + if err := lct.srv.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{ 23 + TextDocument: protocol.TextDocumentItem{URI: uri.File("a.journal"), LanguageID: "journal", Version: 1, Text: "2024-01-01 t\n"}, 24 + }); err != nil { 25 + t.Fatalf("didOpen before initialize: %v", err) 26 + } 27 + // connection is still usable 28 + if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil { 29 + t.Fatalf("initialize after dropped notification: %v", err) 30 + } 31 +} 32 + 33 +func TestLifecycle_InitializeMayOnlyBeSentOnce(t *testing.T) { 34 + lct := startLifecycleTest(t) 35 + if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil { 36 + t.Fatalf("first initialize: %v", err) 37 + } 38 + if _, err := lct.srv.Initialize(t.Context(), initParams); !codeEq(err, jsonrpc2.InvalidRequest) { 39 + t.Fatalf("second initialize: want -32600, got %v", err) 40 + } 41 +} 42 + 43 +func TestLifecycle_RequestsServedAfterInitialize(t *testing.T) { 44 + lct := startLifecycleTest(t) 45 + if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil { 46 + t.Fatal(err) 47 + } 48 + // a request in the running phase must reach the real handler: an unopened 49 + // document yields an empty semantic-tokens result, never a lifecycle error. 50 + if _, err := lct.srv.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{ 51 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.File("nowhere.journal")}, 52 + }); err != nil { 53 + t.Fatalf("request after initialize: %v", err) 54 + } 55 +} 56 + 57 +func TestLifecycle_ExitCodes(t *testing.T) { 58 + for tname, tc := range map[string]struct { 59 + wantCode int 60 + shutdown, init bool 61 + }{ 62 + "exit before initialize": {wantCode: 1}, 63 + "exit after initialize": {wantCode: 1, init: true}, 64 + "exit after shutdown": {wantCode: 0, init: true, shutdown: true}, 65 + } { 66 + t.Run(tname, func(t *testing.T) { 67 + lct := startLifecycleTest(t) 68 + if tc.init { 69 + if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil { 70 + t.Fatal(err) 71 + } 72 + } 73 + if tc.shutdown { 74 + if err := lct.srv.Shutdown(t.Context()); err != nil { 75 + t.Fatal(err) 76 + } 77 + // like gopls, the server trusts its client: a request after 78 + // shutdown is still served (a nonexistent document yields an 79 + // empty hover), never a lifecycle error. 80 + if _, err := lct.srv.Hover(t.Context(), &protocol.HoverParams{}); err != nil { 81 + t.Fatalf("request after shutdown: %v", err) 82 + } 83 + } 84 + if err := lct.srv.Exit(t.Context()); err != nil { 85 + t.Fatal(err) 86 + } 87 + var ee *ExitError 88 + if err := <-lct.errc; !errors.As(err, &ee) || ee.Code != tc.wantCode { 89 + t.Fatalf("want code %d, got %v", tc.wantCode, err) 90 + } 91 + }) 92 + } 93 +} 94 + 95 +func TestLifecycle_ParentDeathExits(t *testing.T) { 96 + if testing.Short() { 97 + t.Skip("spawns a subprocess") 98 + } 99 + 100 + child := exec.Command("sleep", "30") 101 + if err := child.Start(); err != nil { 102 + t.Fatal(err) 103 + } 104 + pid := int32(child.Process.Pid) 105 + child.Process.Kill() 106 + child.Wait() // reap so the immediate signal-0 probe sees ESRCH 107 + 108 + lct := startLifecycleTest(t) 109 + 110 + // initialize in the background: watchParent's immediate probe fires while 111 + // the request is still being handled, so its response is dropped and the 112 + // server exits before the call returns. 113 + go func() { lct.srv.Initialize(t.Context(), &protocol.InitializeParams{ProcessID: &pid}) }() 114 + 115 + select { 116 + case err := <-lct.errc: 117 + var ee *ExitError 118 + if !errors.As(err, &ee) || ee.Code != 1 { 119 + t.Fatalf("parent death: want code 1, got %v", err) 120 + } 121 + case <-time.After(5 * time.Second): 122 + t.Fatal("server did not exit after parent death") 123 + } 124 +} 125 + 126 +var initParams = &protocol.InitializeParams{ 127 + WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{}, 128 + Capabilities: protocol.ClientCapabilities{}, 129 +} 130 + 131 +func codeEq(err error, code jsonrpc2.Code) bool { 132 + return errors.Is(err, jsonrpc2.NewError(code, "")) 133 +} 134 + 135 +type lifecycleTest struct { 136 + errc chan error 137 + srv protocol.Server 138 +} 139 + 140 +func startLifecycleTest(t *testing.T) *lifecycleTest { 141 + t.Helper() 142 + s := newServer(t) 143 + lct := &lifecycleTest{errc: make(chan error, 1)} 144 + inR, inW := io.Pipe() // client -> server 145 + outR, outW := io.Pipe() // server -> client 146 + 147 + go func() { lct.errc <- s.Run(t.Context(), inR, outW) }() 148 + t.Cleanup(func() { 149 + inW.Close() 150 + outR.Close() 151 + }) 152 + 153 + cconn := jsonrpc2.NewConn(jsonrpc2.NewStream(readWriterCloser{Reader: outR, Writer: inW, Closer: outR}), jsonrpc2.WithCodec(lspCodec{})) 154 + lct.srv = protocol.ServerDispatcher(cconn) 155 + cconn.Go(t.Context(), protocol.Handlers(protocol.ClientHandler(&captureClient{}, jsonrpc2.MethodNotFoundHandler))) 156 + return lct 157 +}
M
internal/lsp/lsp.go
··· 46 46 Writer: stdout, 47 47 Closer: stdin, 48 48 }) 49 - 50 - _, conn, client := protocol.NewServer(ctx, s.server, stream) 51 - defer conn.Close() 52 - 53 - s.server.client = client 49 + conn := jsonrpc2.NewConn(stream, jsonrpc2.WithCodec(lspCodec{})) 50 + s.server.client = protocol.ClientDispatcher(conn) 51 + s.server.conn = conn 52 + conn.Go(ctx, protocol.Handlers(s.server.lifecycle(protocol.ServerHandler(s.server, jsonrpc2.MethodNotFoundHandler)))) 54 53 55 54 <-conn.Done() 55 + s.server.stateMu.Lock() 56 + st := s.server.state 57 + s.server.stateMu.Unlock() 58 + if st >= stateExited { 59 + return &ExitError{Code: st.ExitCode()} 60 + } 56 61 return conn.Err() 57 62 } 58 63 ··· 82 87 } 83 88 return os.OpenFile(filepath.Join(dir, "lsp.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) 84 89 } 90 + 91 +// lspCodec mirros go.lsp.dev/protocol wire codec, so Run can install the lifecycle guard. 92 +type lspCodec struct{} 93 + 94 +func (lspCodec) Marshal(v any) ([]byte, error) { 95 + switch m := v.(type) { 96 + case jsonrpc2.RawMessage: 97 + if m == nil { 98 + return []byte("null"), nil 99 + } 100 + return m, nil 101 + case *jsonrpc2.RawMessage: 102 + if m == nil || *m == nil { 103 + return []byte("null"), nil 104 + } 105 + return *m, nil 106 + } 107 + return protocol.Marshal(v) 108 +} 109 + 110 +func (lspCodec) Unmarshal(data []byte, v any) error { 111 + if p, ok := v.(*jsonrpc2.RawMessage); ok { 112 + b := make(jsonrpc2.RawMessage, len(data)) 113 + copy(b, data) 114 + *p = b 115 + return nil 116 + } 117 + return protocol.Unmarshal(data, v) 118 +}
M
internal/lsp/server.go
··· 10 10 11 11 "github.com/go-json-experiment/json" 12 12 "github.com/pelletier/go-toml/v2" 13 + "go.lsp.dev/jsonrpc2" 13 14 "go.lsp.dev/protocol" 14 15 "go.lsp.dev/uri" 15 16 ··· 23 24 24 25 client protocol.Client 25 26 log *slog.Logger 27 + conn jsonrpc2.Conn // set in Run; Exit closes it to end the session 26 28 29 + stateMu sync.Mutex 30 + state serverState // [serverState] 27 31 version, name string 28 32 29 33 settings settings.Settings ··· 46 50 return nil 47 51 } 48 52 49 -// analysisFor returns the cached analysis for an open doc, rebuilds when the doc or a file it inclues changed. 53 +// analysisFor returns the cached analysis for an open doc, rebuilding when the doc or a file it includes changed. 50 54 func (s *server) analysisFor(u uri.URI) *analyzer.Analysis { 51 55 s.mu.RLock() 52 56 state, ok := s.openDocs[u] ··· 55 59 return nil 56 60 } 57 61 if !state.dirty { 58 - an := state.analysis 62 + an := state.cache.analysis 59 63 s.mu.RUnlock() 60 64 return an 61 65 } ··· 68 72 s.mu.Lock() 69 73 state, ok = s.openDocs[u] 70 74 if !ok || state.version != version { 71 - // editot or closed while building. doc stays dirty so te request rebuilds 75 + // editor closed or edited while building; the doc stays dirty so the next request rebuilds 72 76 s.mu.Unlock() 73 77 return an 74 78 } 75 - state.analysis = an 79 + state.cache = newAnalysisCache(an) 76 80 state.dirty = false 77 - state.paths = make(map[string]bool, len(an.Files)) 78 - for _, pf := range an.Files { 79 - state.paths[journal.CanonicalPath(pf.Path)] = true 80 - } 81 81 s.openDocs[u] = state 82 82 s.mu.Unlock() 83 83 return an ··· 93 93 if err := s.applySettings(ctx, params.InitializationOptions); err != nil { 94 94 return nil, err 95 95 } 96 + td := params.Capabilities.TextDocument 96 97 full := protocol.SemanticTokensOptionsFull(protocol.Boolean(true)) 97 - if td := params.Capabilities.TextDocument; td != nil { 98 + if td != nil { 98 99 if fd, ok := td.SemanticTokens.Requests.Full.(*protocol.ClientSemanticTokensRequestFullDelta); ok && fd.Delta != nil && *fd.Delta { 99 100 full = &protocol.SemanticTokensFullDelta{Delta: new(true)} 100 101 } 101 102 } 102 103 104 + // RenameOptions may only be specified when the client states prepare support. 105 + renameProvider := protocol.RenameProvider(&protocol.RenameOptions{PrepareProvider: new(true)}) 106 + if td == nil || td.Rename == nil || td.Rename.PrepareSupport == nil || !*td.Rename.PrepareSupport { 107 + renameProvider = protocol.Boolean(true) 108 + } 109 + 110 + s.stateMu.Lock() 111 + if s.state != stateNew { 112 + s.stateMu.Unlock() 113 + return nil, errInitializeOnce 114 + } 115 + s.state = stateInitialized 116 + s.stateMu.Unlock() 117 + if p := params.ProcessID; p != nil { 118 + s.watchParent(*p) 119 + } 120 + 103 121 return &protocol.InitializeResult{ 104 122 ServerInfo: protocol.ServerInfo{ 105 123 Name: s.name, ··· 114 132 DocumentSymbolProvider: protocol.Boolean(true), 115 133 FoldingRangeProvider: protocol.Boolean(true), 116 134 SelectionRangeProvider: protocol.Boolean(true), 117 - RenameProvider: &protocol.RenameOptions{ 118 - PrepareProvider: new(true), 119 - }, 135 + RenameProvider: renameProvider, 120 136 CompletionProvider: &protocol.CompletionOptions{ 121 137 TriggerCharacters: []string{":", "@"}, 122 138 }, ··· 164 180 } 165 181 166 182 func (s *server) Shutdown(ctx context.Context) error { 183 + s.stateMu.Lock() 184 + if s.state == stateInitialized { 185 + s.state = stateShutdownRequested 186 + } 187 + s.stateMu.Unlock() 167 188 return nil 168 189 } 169 190 170 -func (s *server) Exit(ctx context.Context) error { 191 +// Exit records the exit code the LSP spec prescribes — 0 after a shutdown 192 +// request, 1 otherwise — and closes the connection to end the session. The 193 +// close runs in its own goroutine: called from inside the exit-notification 194 +// handler it cannot block on the connection draining (see [jsonrpc2.Conn.Close]). 195 +// Exit honors an exit that never saw initialize, and is safe to call 196 +// concurrently from the exit notification and watchParent: the stateMu guard 197 +// below makes the transition a once-only, and an exit that lost the race to 198 +// another exit is a no-op. 199 +func (s *server) Exit(context.Context) error { 200 + s.stateMu.Lock() 201 + if s.state < stateExited { 202 + if s.state == stateShutdownRequested { 203 + s.state = stateExitedAfterShutdown 204 + } else { 205 + s.state = stateExited 206 + } 207 + } 208 + s.stateMu.Unlock() 209 + go s.conn.Close() 171 210 return nil 172 211 } 173 212
M
internal/lsp/server_test.go
··· 7 7 "strings" 8 8 "sync" 9 9 "testing" 10 - "time" 11 10 12 11 "go.lsp.dev/protocol" 13 12 "go.lsp.dev/uri" ··· 92 91 open(a, aContent) 93 92 open(b, bContent) 94 93 95 - waitFor(t, "diagnostics for the unbalanced transaction", func() bool { 96 - da, _ := capture.lastDiags(a) 97 - return len(da) != 0 98 - }) 94 + // publishDiagnostics is invoked directly so the test is deterministic: the 95 + // didOpen/didChange handlers debounce publishing by 200ms in production. 96 + srv.server.publishDiagnostics(t.Context()) 97 + da, _ := capture.lastDiags(a) 98 + if len(da) == 0 { 99 + t.Fatal("expected diagnostics for the unbalanced transaction") 100 + } 99 101 100 102 aEdited := "account expenses:food\naccount assets:cash\naccount assets:bank\ncommodity $\npayee test\n\n2024-01-01 * test\n expenses:food $20.00\n assets:cash $-10.00\n assets:bank $-10.00\n" 101 103 if err := srv.server.DidChange(t.Context(), &protocol.DidChangeTextDocumentParams{ ··· 110 112 t.Fatalf("didChange a: %v", err) 111 113 } 112 114 113 - waitFor(t, "a diagnostics to clear after the edit", func() bool { 114 - da, _ := capture.lastDiags(a) 115 - return len(da) == 0 116 - }) 115 + srv.server.publishDiagnostics(t.Context()) 116 + if da, _ := capture.lastDiags(a); len(da) != 0 { 117 + t.Errorf("diagnostics not cleared after the edit: %v", da) 118 + } 117 119 118 120 if err := srv.server.DidClose(t.Context(), &protocol.DidCloseTextDocumentParams{ 119 121 TextDocument: protocol.TextDocumentIdentifier{URI: a}, ··· 137 139 a1 := srv.server.analysisFor(uBase) 138 140 139 141 testutil.WriteFile(t, base, []byte("2024-01-01 t\n expenses:food $10\n assets:bank\n")) 140 - if err := srv.server.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{ 142 + if err := srv.server.DidChangeWatchedFiles(t.Context(), &protocol.DidChangeWatchedFilesParams{ 141 143 Changes: []protocol.FileEvent{{URI: uBase, Type: protocol.FileChangeTypeChanged}}, 142 144 }); err != nil { 143 145 t.Fatalf("didChangeWatchedFiles: %v", err) ··· 158 160 srv := newServer(t) 159 161 srv.server.client = &captureClient{} 160 162 uMain := uri.File(main) 161 - if err := srv.server.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ 163 + if err := srv.server.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{ 162 164 TextDocument: protocol.TextDocumentItem{URI: uMain, LanguageID: "journal", Version: 1, Text: "include base.journal\n"}, 163 165 }); err != nil { 164 166 t.Fatalf("didOpen: %v", err) ··· 171 173 172 174 // base changes on disk, outside the editor 173 175 testutil.WriteFile(t, base, []byte("2024-01-01 t\n expenses:food $10\n assets:bank\n")) 174 - if err := srv.server.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{ 176 + if err := srv.server.DidChangeWatchedFiles(t.Context(), &protocol.DidChangeWatchedFilesParams{ 175 177 Changes: []protocol.FileEvent{{URI: uri.File(base), Type: protocol.FileChangeTypeChanged}}, 176 178 }); err != nil { 177 179 t.Fatalf("didChangeWatchedFiles: %v", err) ··· 291 293 } 292 294 } 293 295 return nil, false 294 -} 295 - 296 -func waitFor(t *testing.T, what string, cond func() bool) { 297 - t.Helper() 298 - deadline := time.Now().Add(5 * time.Second) 299 - for time.Now().Before(deadline) { 300 - if cond() { 301 - return 302 - } 303 - time.Sleep(10 * time.Millisecond) 304 - } 305 - t.Fatalf("timed out waiting for %s", what) 306 296 } 307 297 308 298 func newServer(tb testing.TB) Server {
M
internal/lsp/testdata/semantic-journal.txtar
··· 136 136 14:0+10 class 137 137 14:11+10 class 138 138 14:22+9 property 139 -15:2+2 operator 139 +15:2+1 operator 140 140 15:4+11 namespace 141 141 15:17+1 type 142 142 15:18+2 number 143 -16:2+2 operator 143 +16:2+1 operator 144 144 16:4+1 operator abstract 145 145 16:5+11 namespace 146 146 16:16+1 operator abstract ··· 193 193 37:7+1 type 194 194 37:8+3 number negative 195 195 39:0+10 class 196 -39:11+2 operator 196 +39:11+1 operator 197 197 39:13+6 string 198 198 39:20+4 property 199 199 39:27+6 property ··· 231 231 48:31+3 number 232 232 49:4+23 comment 233 233 51:0+10 class 234 -51:11+2 operator 234 +51:11+1 operator 235 235 51:13+4 property 236 236 51:19+13 comment 237 237 52:4+13 namespace ··· 239 239 53:21+1 type 240 240 53:22+3 number 241 241 55:0+10 class 242 -55:11+2 operator 242 +55:11+1 operator 243 243 55:13+4 property 244 244 56:4+1 namespace 245 245 56:7+1 type ··· 313 313 72:11+6 string 314 314 72:18+5 property 315 315 74:0+10 class 316 -74:11+2 operator 316 +74:11+1 operator 317 317 74:13+4 property 318 318 75:2+6 namespace 319 319 76:2+8 namespace 320 320 76:12+14 comment 321 321 78:0+10 class 322 -78:11+2 operator 322 +78:11+1 operator 323 323 78:13+4 property 324 324 80:0+10 class 325 325 80:11+2 property
M
internal/lsp/textdocument_hover_test.go
··· 1 1 package lsp 2 2 3 3 import ( 4 - "context" 5 4 "fmt" 6 5 "strings" 7 6 "testing" ··· 45 44 46 45 func TestServer_Hover_DocumentNotFound(t *testing.T) { 47 46 srv := newServer(t) 48 - res, err := srv.server.Hover(context.Background(), &protocol.HoverParams{ 47 + res, err := srv.server.Hover(t.Context(), &protocol.HoverParams{ 49 48 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 50 49 Position: protocol.Position{Line: 0, Character: 0}, 51 50 })
M
internal/lsp/textdocument_references_test.go
··· 1 1 package lsp 2 2 3 3 import ( 4 - "context" 5 4 "fmt" 6 5 "path/filepath" 7 6 "strings" ··· 17 16 18 17 func TestServer_References_DocumentNotFound(t *testing.T) { 19 18 srv := newServer(t) 20 - res, err := srv.server.References(context.Background(), &protocol.ReferenceParams{ 19 + res, err := srv.server.References(t.Context(), &protocol.ReferenceParams{ 21 20 TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")}, 22 21 Position: protocol.Position{Line: 0, Character: 0}, 23 22 })
M
internal/lsp/textdocument_sync.go
··· 55 55 text string 56 56 version int32 57 57 languageID protocol.LanguageKind 58 - paths map[string]bool // canonical paths of every file in the cached analysis 59 58 lineIdx *lsputil.LineIndex // cached line index for the text 60 59 61 - analysis *analyzer.Analysis // cached analysis, nil until first build 62 - sem *semCache // semantic token cache, nil until first tokenize 63 - dirty bool // true while the cached analysis may not reflect the current text 60 + dirty bool // true while the cached analysis may not reflect the current text 61 + cache *analysisCache // nil until first build 62 + sem *semCache // nil until first tokenize 63 +} 64 + 65 +type analysisCache struct { 66 + analysis *analyzer.Analysis 67 + fileIdx map[string]*lsputil.LineIndex 68 + paths map[string]bool 69 +} 70 + 71 +func newAnalysisCache(an *analyzer.Analysis) *analysisCache { 72 + paths := make(map[string]bool, len(an.Files)) 73 + for _, pf := range an.Files { 74 + paths[journal.CanonicalPath(pf.Path)] = true 75 + } 76 + return &analysisCache{analysis: an, paths: paths, fileIdx: buildFileIdx(an)} 77 +} 78 + 79 +func buildFileIdx(an *analyzer.Analysis) map[string]*lsputil.LineIndex { 80 + idx := make(map[string]*lsputil.LineIndex, len(an.Files)) 81 + for _, pf := range an.Files { 82 + idx[pf.Path] = lsputil.NewLineIndex(string(pf.Src)) 83 + } 84 + return idx 64 85 } 65 86 66 87 func (s *server) openDoc(u uri.URI, text string, version int32, langID protocol.LanguageKind) { ··· 114 135 state.lineIdx = lsputil.NewLineIndex(state.text) 115 136 } 116 137 117 - state.analysis = nil 138 + state.cache = nil 118 139 state.dirty = true 119 140 if incremental { 120 141 state.sem.pending = &edit ··· 133 154 canon := journal.CanonicalPath(u.Path()) 134 155 s.mu.Lock() 135 156 for du, dstate := range s.openDocs { 136 - if dstate.paths[canon] { 157 + if dstate.cache == nil { 158 + continue 159 + } 160 + if dstate.cache.paths[canon] { 137 161 dstate.dirty = true 138 162 s.openDocs[du] = dstate 139 163 }
M
internal/lsp/workspace_symbol_test.go
··· 1 1 package lsp 2 2 3 3 import ( 4 - "context" 5 4 "fmt" 6 5 "path/filepath" 7 6 "strings" ··· 37 36 u := uri.URI("file:///test.journal") 38 37 srv.server.openDoc(u, "account expenses:food\n", 1, "journal") 39 38 40 - res, err := srv.server.Symbols(context.Background(), &protocol.WorkspaceSymbolParams{Query: ""}) 39 + res, err := srv.server.Symbols(t.Context(), &protocol.WorkspaceSymbolParams{Query: ""}) 41 40 if err != nil { 42 41 t.Fatal(err) 43 42 }