22 files changed,
609 insertions(+),
227 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-13 17:06:52 +0300
Authored at:
2026-08-13 13:54:45 +0300
Change ID:
nuxznvpsvxrlnvnkpzlxopnmwtruxkpp
Parent:
8921805
jump to
M
internal/lsp/diagnostics.go
··· 40 40 return 41 41 } 42 42 43 - a := s.buildAnalysis() 44 - if a == nil { 45 - s.log.Debug("no files in workspace") 46 - return 43 + s.mu.Lock() 44 + var dirtyURIs []uri.URI 45 + for u, state := range s.openDocs { 46 + if state.dirty { 47 + dirtyURIs = append(dirtyURIs, u) 48 + } 47 49 } 48 - 49 - activePaths := make(map[string]bool, len(a.Files)) 50 - for _, pf := range a.Files { 51 - activePaths[pf.Path] = true 52 - } 53 - 54 - if ctx.Err() != nil { 50 + s.mu.Unlock() 51 + if len(dirtyURIs) == 0 { 52 + s.log.Debug("no dirty files") 55 53 return 56 54 } 57 55 58 - finds := dedupFinds(s.linter.Run(a)) 59 - 60 - s.mu.Lock() 61 - s.current = a 62 - s.mu.Unlock() 56 + // Rebuild every dirty doc and publish the union of their files; 57 + // the same included file may appear in several trees and must be published once. 58 + var finds []linter.Find 59 + paths := make(map[string]bool) 60 + for _, u := range dirtyURIs { 61 + a := s.analysisFor(u) 62 + if a == nil { 63 + continue 64 + } 65 + for _, pf := range a.Files { 66 + paths[pf.Path] = true 67 + } 68 + finds = append(finds, s.linter.Run(a)...) 69 + } 63 70 64 71 if ctx.Err() != nil { 65 72 return 66 73 } 67 74 68 - diagsByFile := s.groupFindsByFile(finds) 69 - 70 - for fpath := range activePaths { 75 + diagsByFile := s.groupFindsByFile(dedupFinds(finds)) 76 + for fpath := range paths { 71 77 if ctx.Err() != nil { 72 78 return 73 79 } ··· 79 85 } 80 86 } 81 87 82 - s.log.Debug("diagnostics published", "files", len(a.Files), "findings", len(finds)) 88 + s.log.Debug("diagnostics published", "files", len(paths), "findings", len(finds)) 83 89 } 84 90 85 91 func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic {
M
internal/lsp/diagnostics_test.go
··· 3 3 import ( 4 4 "testing" 5 5 6 - "go.lsp.dev/uri" 6 + "olexsmir.xyz/clerk/internal/analyzer" 7 + "olexsmir.xyz/clerk/journal" 7 8 ) 8 9 9 10 func BenchmarkDiagnostics(b *testing.B) { 10 11 content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 11 - 12 12 srv := NewServer("test") 13 - srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 14 13 14 + // Per-edit cost: a fresh loader skips the parse cache, so each iteration re-parses, then lints and groups findings. 15 15 b.ReportAllocs() 16 16 b.ResetTimer() 17 17 for b.Loop() { 18 - // Per-edit cost: re-resolve (parse) + lint + group findings. 19 - a := srv.server.buildAnalysis() 20 - finds := dedupFinds(srv.server.linter.Run(a)) 18 + an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", []byte(content))) 19 + finds := dedupFinds(srv.server.linter.Run(an)) 21 20 _ = srv.server.groupFindsByFile(finds) 22 21 } 23 22 }
M
internal/lsp/lsp.go
··· 20 20 type Server struct{ server *server } 21 21 22 22 func NewServer(version string) Server { 23 - var logger *slog.Logger 24 - logFile, err := openLogFile() 25 - if err == nil { 23 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 24 + if logFile, err := openLogFile(); err == nil { 26 25 logger = slog.New(slog.NewTextHandler(logFile, nil)) 27 - } else { 28 - logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) 29 26 } 30 27 31 - return Server{ 32 - &server{ 33 - name: "clerk", 34 - version: version, 28 + srv := &server{ 29 + name: "clerk", 30 + version: version, 35 31 36 - openDocs: make(map[uri.URI]docState), 32 + openDocs: make(map[uri.URI]docState), 37 33 38 - config: DefaultConfig, 39 - linter: linter.NewLinter(linter.Rules), 40 - loader: journal.NewLoader(), 41 - printer: printer.DefaultConfig, 34 + config: DefaultConfig, 35 + linter: linter.NewLinter(linter.Rules), 36 + loader: journal.NewLoader(), 37 + printer: printer.DefaultConfig, 42 38 43 - log: logger, 44 - }, 45 - } 46 -} 47 - 48 -func openLogFile() (*os.File, error) { 49 - dir, err := xdg.StateDir() 50 - if err != nil { 51 - return nil, err 52 - } 53 - dir = filepath.Join(dir, "clerk") 54 - if err := os.MkdirAll(dir, 0o700); err != nil { 55 - return nil, err 39 + log: logger, 56 40 } 57 - return os.OpenFile(filepath.Join(dir, "lsp.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) 41 + srv.loader.ContentProvider = srv.bufferContent 42 + return Server{srv} 58 43 } 59 44 60 45 func (s *Server) Run(ctx context.Context, stdin io.ReadCloser, stdout io.WriteCloser) error { ··· 73 58 return conn.Err() 74 59 } 75 60 61 +// bufferContent returns the open buffer text for a path, if any. 62 +// called by the loader during include resolution; must NOT hold the loader lock. 63 +func (s *server) bufferContent(path string) ([]byte, bool) { 64 + s.mu.Lock() 65 + defer s.mu.Unlock() 66 + st, ok := s.openDocs[uri.File(path)] 67 + return []byte(st.text), ok 68 +} 69 + 76 70 type readWriterCloser struct { 77 71 io.Reader 78 72 io.Writer 79 73 io.Closer 80 74 } 75 + 76 +func openLogFile() (*os.File, error) { 77 + dir, err := xdg.StateDir() 78 + if err != nil { 79 + return nil, err 80 + } 81 + dir = filepath.Join(dir, "clerk") 82 + if err := os.MkdirAll(dir, 0o700); err != nil { 83 + return nil, err 84 + } 85 + return os.OpenFile(filepath.Join(dir, "lsp.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) 86 +}
M
internal/lsp/server.go
··· 3 3 import ( 4 4 "context" 5 5 "log/slog" 6 - "maps" 7 6 "sync" 8 7 9 8 "go.lsp.dev/protocol" ··· 36 35 config Config 37 36 } 38 37 39 -func (s *server) analysis() *analyzer.Analysis { 38 +// analysisFor returns the cached analysis for an open doc, rebuilds when the doc or a file it inclues changed. 39 +func (s *server) analysisFor(u uri.URI) *analyzer.Analysis { 40 40 s.mu.Lock() 41 - a := s.current 42 - s.mu.Unlock() 43 - if a != nil { 44 - return a 41 + state, ok := s.openDocs[u] 42 + if !ok { 43 + s.mu.Unlock() 44 + return nil 45 45 } 46 - return s.buildAnalysis() 47 -} 48 - 49 -func (s *server) buildAnalysis() *analyzer.Analysis { 50 - s.mu.Lock() 51 - docs := make(map[uri.URI]docState, len(s.openDocs)) 52 - maps.Copy(docs, s.openDocs) 46 + if !state.dirty { 47 + an := state.analysis 48 + s.mu.Unlock() 49 + return an 50 + } 51 + text := state.text 52 + version := state.version 53 53 s.mu.Unlock() 54 54 55 - var a *analyzer.Analysis 56 - for duri, state := range docs { 57 - rj := s.loader.ResolveBytes(duri.Path(), []byte(state.text)) 58 - if a == nil { 59 - a = analyzer.Build(rj) 60 - } 55 + an := analyzer.Build(s.loader.ResolveBytes(u.Path(), []byte(text))) 56 + 57 + s.mu.Lock() 58 + state, ok = s.openDocs[u] 59 + if !ok || state.version != version { 60 + // editot or closed while building. doc stays dirty so te request rebuilds 61 + s.mu.Unlock() 62 + return an 63 + } 64 + state.analysis = an 65 + state.dirty = false 66 + state.paths = make(map[string]bool, len(an.Files)) 67 + for _, pf := range an.Files { 68 + state.paths[journal.CanonicalPath(pf.Path)] = true 61 69 } 62 - return a 70 + s.openDocs[u] = state 71 + s.mu.Unlock() 72 + return an 63 73 } 64 74 65 75 func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {
A
internal/lsp/server_test.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "path/filepath" 6 + "slices" 7 + "sync" 8 + "testing" 9 + "time" 10 + 11 + "go.lsp.dev/protocol" 12 + "go.lsp.dev/uri" 13 + 14 + "olexsmir.xyz/clerk/internal/testutil" 15 +) 16 + 17 +func TestAnalysisFor_CachedAndRebuilt(t *testing.T) { 18 + u := uri.File(filepath.Join(t.TempDir(), "a.journal")) 19 + srv := NewServer("test") 20 + srv.server.openDoc(u, "2024-01-01 t\n expenses:food $10\n assets:cash\n", 1, "journal") 21 + 22 + a1 := srv.server.analysisFor(u) 23 + if a1 == nil { 24 + t.Fatal("analysisFor returned nil") 25 + } 26 + if a2 := srv.server.analysisFor(u); a2 != a1 { 27 + t.Error("cached analysis not reused") 28 + } 29 + 30 + srv.server.updateDoc(u, 2, []protocol.TextDocumentContentChangeEvent{ 31 + &protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-02 t\n expenses:travel $20\n assets:cash\n"}, 32 + }) 33 + a3 := srv.server.analysisFor(u) 34 + if a3 == a1 { 35 + t.Error("edit did not rebuild the analysis") 36 + } 37 + if !slices.Contains(a3.AccountNames, "expenses:travel") || slices.Contains(a3.AccountNames, "expenses:food") { 38 + t.Errorf("stale accounts after edit: %v", a3.AccountNames) 39 + } 40 +} 41 + 42 +func TestAnalysisFor_DependentDirty(t *testing.T) { 43 + dir := t.TempDir() 44 + base := filepath.Join(dir, "base.journal") 45 + main := filepath.Join(dir, "main.journal") 46 + testutil.WriteFile(t, base, []byte("2024-01-01 t\n expenses:food $10\n assets:cash\n")) 47 + testutil.WriteFile(t, main, []byte("include base.journal\n")) 48 + 49 + srv := NewServer("test") 50 + uMain, uBase := uri.File(main), uri.File(base) 51 + srv.server.openDoc(uMain, "include base.journal\n", 1, "journal") 52 + srv.server.openDoc(uBase, "2024-01-01 t\n expenses:food $10\n assets:cash\n", 1, "journal") 53 + 54 + aMain := srv.server.analysisFor(uMain) 55 + if !slices.Contains(aMain.AccountNames, "assets:cash") { 56 + t.Fatalf("main analysis missing included account: %v", aMain.AccountNames) 57 + } 58 + 59 + srv.server.updateDoc(uBase, 2, []protocol.TextDocumentContentChangeEvent{ 60 + &protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-01 t\n expenses:food $10\n assets:bank\n"}, 61 + }) 62 + aMain2 := srv.server.analysisFor(uMain) 63 + if !slices.Contains(aMain2.AccountNames, "assets:bank") { 64 + t.Errorf("dependent analysis not rebuilt with new buffer content: %v", aMain2.AccountNames) 65 + } 66 +} 67 + 68 +func TestServer_Diagnostics(t *testing.T) { 69 + dir := t.TempDir() 70 + a := uri.File(filepath.Join(dir, "a.journal")) 71 + b := uri.File(filepath.Join(dir, "b.journal")) 72 + 73 + aContent := "account expenses:food\naccount assets:cash\ncommodity $\npayee test\n\n2024-01-01 * test\n expenses:food $10.00\n assets:cash $5.00\n" 74 + bContent := "include a.journal\n" 75 + testutil.WriteFile(t, a.Path(), []byte(aContent)) 76 + testutil.WriteFile(t, b.Path(), []byte(bContent)) 77 + 78 + srv := NewServer("test") 79 + capture := &captureClient{} 80 + srv.server.client = capture 81 + 82 + open := func(u uri.URI, content string) { 83 + t.Helper() 84 + if err := srv.server.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{ 85 + TextDocument: protocol.TextDocumentItem{URI: u, LanguageID: "journal", Version: 1, Text: content}, 86 + }); err != nil { 87 + t.Fatalf("didOpen %s: %v", u, err) 88 + } 89 + } 90 + open(a, aContent) 91 + open(b, bContent) 92 + 93 + waitFor(t, "diagnostics for the unbalanced transaction", func() bool { 94 + da, _ := capture.lastDiags(a) 95 + return len(da) != 0 96 + }) 97 + 98 + 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" 99 + if err := srv.server.DidChange(t.Context(), &protocol.DidChangeTextDocumentParams{ 100 + TextDocument: protocol.VersionedTextDocumentIdentifier{ 101 + TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: a}, 102 + Version: 2, 103 + }, 104 + ContentChanges: []protocol.TextDocumentContentChangeEvent{ 105 + &protocol.TextDocumentContentChangeWholeDocument{Text: aEdited}, 106 + }, 107 + }); err != nil { 108 + t.Fatalf("didChange a: %v", err) 109 + } 110 + 111 + waitFor(t, "a diagnostics to clear after the edit", func() bool { 112 + da, _ := capture.lastDiags(a) 113 + return len(da) == 0 114 + }) 115 + 116 + if err := srv.server.DidClose(t.Context(), &protocol.DidCloseTextDocumentParams{ 117 + TextDocument: protocol.TextDocumentIdentifier{URI: a}, 118 + }); err != nil { 119 + t.Fatalf("didClose a: %v", err) 120 + } 121 + if da, _ := capture.lastDiags(a); len(da) != 0 { 122 + t.Errorf("diagnostics not cleared on close: %v", da) 123 + } 124 +} 125 + 126 +type captureClient struct { 127 + protocol.Client 128 + mu sync.Mutex 129 + diag []protocol.PublishDiagnosticsParams 130 +} 131 + 132 +func (c *captureClient) PublishDiagnostics(_ context.Context, params *protocol.PublishDiagnosticsParams) error { 133 + c.mu.Lock() 134 + c.diag = append(c.diag, *params) 135 + c.mu.Unlock() 136 + return nil 137 +} 138 + 139 +func (c *captureClient) lastDiags(u uri.URI) ([]protocol.Diagnostic, bool) { 140 + c.mu.Lock() 141 + defer c.mu.Unlock() 142 + for _, v := range slices.Backward(c.diag) { 143 + if v.URI == u { 144 + return v.Diagnostics, true 145 + } 146 + } 147 + return nil, false 148 +} 149 + 150 +func waitFor(t *testing.T, what string, cond func() bool) { 151 + t.Helper() 152 + deadline := time.Now().Add(5 * time.Second) 153 + for time.Now().Before(deadline) { 154 + if cond() { 155 + return 156 + } 157 + time.Sleep(10 * time.Millisecond) 158 + } 159 + t.Fatalf("timed out waiting for %s", what) 160 +}
M
internal/lsp/textdocument_completion.go
··· 30 30 if detectedCtx == cmplNone { 31 31 return &protocol.CompletionList{}, nil 32 32 } 33 - a := s.analysis() 34 - if a == nil { 33 + an := s.analysisFor(params.TextDocument.URI) 34 + if an == nil { 35 35 return &protocol.CompletionList{}, nil 36 36 } 37 37 return &protocol.CompletionList{ 38 38 IsIncomplete: true, 39 - Items: cmplItems(a, detectedCtx, state.text, state.lineIdx, start, cursor), 39 + Items: cmplItems(an, detectedCtx, state.text, state.lineIdx, start, cursor), 40 40 }, nil 41 41 } 42 42
M
internal/lsp/textdocument_completion_test.go
··· 1 +// TODO: 1 2 package lsp 2 3 3 4 import ( ··· 9 10 "go.lsp.dev/protocol" 10 11 "go.lsp.dev/uri" 11 12 12 - "olexsmir.xyz/clerk/internal/analyzer" 13 13 "olexsmir.xyz/clerk/internal/lsp/lsputil" 14 14 "olexsmir.xyz/clerk/internal/testutil/golden" 15 15 "olexsmir.xyz/clerk/journal" ··· 83 83 if err != nil { 84 84 b.Fatal(err) 85 85 } 86 - a := analyzer.Build(rj) 87 86 content := string(rj.Occurrences[0].Src) 88 87 89 88 srv := NewServer("test") 90 89 srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 91 - srv.server.current = a 90 + srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache 92 91 93 92 for tname, tt := range map[string]int{ 94 93 "1k txns, account": strings.Index(content, "\n 1:2:3 ") + len("\n ") + 2,
M
internal/lsp/textdocument_definition.go
··· 20 20 return nil, nil 21 21 } 22 22 23 - an := s.analysis() 23 + an := s.analysisFor(params.TextDocument.URI) 24 24 cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character)) 25 25 return findDefinitionUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor), nil 26 26 }
M
internal/lsp/textdocument_definition_test.go
··· 9 9 "go.lsp.dev/protocol" 10 10 "go.lsp.dev/uri" 11 11 12 - "olexsmir.xyz/clerk/internal/analyzer" 13 12 "olexsmir.xyz/clerk/internal/lsp/lsputil" 14 13 "olexsmir.xyz/clerk/internal/testutil/golden" 15 14 "olexsmir.xyz/clerk/journal" ··· 71 70 if err != nil { 72 71 b.Fatal(err) 73 72 } 74 - a := analyzer.Build(rj) 75 73 content := string(rj.Occurrences[0].Src) 76 74 77 75 srv := NewServer("test") 78 76 u := uri.File(abs) 79 77 srv.server.openDoc(u, content, 1, "journal") 80 - srv.server.current = a 78 + srv.server.analysisFor(u) // warm the per-doc cache 81 79 82 80 for tname, tt := range map[string]int{ 83 81 "1k txns, account": strings.Index(content, "\n 1:2:3 ") + len("\n ") + 2,
M
internal/lsp/textdocument_hover.go
··· 18 18 return nil, nil 19 19 } 20 20 21 - an := s.analysis() 21 + an := s.analysisFor(params.TextDocument.URI) 22 22 cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character)) 23 23 el := hoverAt(an, params.TextDocument.URI.Path(), state.text, cursor) 24 24 if el == nil {
M
internal/lsp/textdocument_hover_test.go
··· 13 13 "olexsmir.xyz/clerk/internal/analyzer" 14 14 "olexsmir.xyz/clerk/internal/lsp/lsputil" 15 15 "olexsmir.xyz/clerk/internal/testutil/golden" 16 - "olexsmir.xyz/clerk/journal" 17 16 "olexsmir.xyz/clerk/journal/ast" 18 17 "olexsmir.xyz/clerk/journal/token" 19 18 ) ··· 109 108 func BenchmarkHover(b *testing.B) { 110 109 content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal") 111 110 112 - // Path must match the open doc: hoverAt resolves the document by path. 113 - an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", []byte(content))) 114 - 115 111 srv := NewServer("test") 116 112 srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 117 - srv.server.current = an 113 + srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache 118 114 119 115 for tname, tt := range map[string]int{ 120 116 "date": strings.Index(content, "2000-01-01 transaction 1") + 3,
M
internal/lsp/textdocument_rename.go
··· 21 21 return nil, nil 22 22 } 23 23 24 - an := s.analysis() 24 + an := s.analysisFor(params.TextDocument.URI) 25 25 cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character)) 26 26 ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor) 27 27 if ref == nil { ··· 40 40 return nil, nil 41 41 } 42 42 43 - an := s.analysis() 43 + an := s.analysisFor(params.TextDocument.URI) 44 44 cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character)) 45 45 ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor) 46 46 if ref == nil {
M
internal/lsp/textdocument_rename_test.go
··· 11 11 "go.lsp.dev/protocol" 12 12 "go.lsp.dev/uri" 13 13 14 - "olexsmir.xyz/clerk/internal/analyzer" 15 14 "olexsmir.xyz/clerk/internal/lsp/lsputil" 16 15 "olexsmir.xyz/clerk/internal/testutil/golden" 17 16 "olexsmir.xyz/clerk/journal" ··· 137 136 if err != nil { 138 137 b.Fatal(err) 139 138 } 140 - a := analyzer.Build(rj) 141 139 content := string(rj.Occurrences[0].Src) 142 140 143 141 srv := NewServer("test") 144 142 u := uri.File(abs) 145 143 srv.server.openDoc(u, content, 1, "journal") 146 - srv.server.current = a 144 + srv.server.analysisFor(u) // warm the per-doc cache 147 145 148 146 for tname, tt := range map[string]struct { 149 147 newName string
M
internal/lsp/textdocument_sync.go
··· 6 6 "go.lsp.dev/protocol" 7 7 "go.lsp.dev/uri" 8 8 9 + "olexsmir.xyz/clerk/internal/analyzer" 9 10 "olexsmir.xyz/clerk/internal/lsp/lsputil" 11 + "olexsmir.xyz/clerk/journal" 10 12 "olexsmir.xyz/clerk/journal/ast" 11 13 "olexsmir.xyz/clerk/journal/lexer" 12 14 "olexsmir.xyz/clerk/journal/parser" ··· 25 27 } 26 28 27 29 func (s *server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error { 28 - s.closeDoc(params.TextDocument.URI) 30 + u := params.TextDocument.URI 31 + s.closeDoc(u) 32 + 33 + // clear closed doc's diagnostics; dependents rebuild from disk since the buffer text is gone. 34 + s.markDependentsDirty(u) 35 + if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ 36 + URI: u, 37 + Diagnostics: []protocol.Diagnostic{}, 38 + }); err != nil { 39 + s.log.Warn("clear diagnostics failed", "uri", u, "err", err) 40 + } 41 + 29 42 s.scheduleDiagnostics(ctx) 30 43 return nil 31 44 } ··· 42 55 languageID protocol.LanguageKind 43 56 semTokens []semanticToken // cached semantic tokens 44 57 lineIdx *lsputil.LineIndex // cached line index for the text 58 + analysis *analyzer.Analysis // cached analysis, nil until first build 59 + paths map[string]bool // canonical paths of every file in the cached analysis 60 + dirty bool // true while the cached analysis may not reflect the current text 45 61 } 46 62 47 63 func (s *server) openDoc(u uri.URI, text string, version int32, langID protocol.LanguageKind) { ··· 51 67 version: version, 52 68 languageID: langID, 53 69 lineIdx: lsputil.NewLineIndex(text), 70 + dirty: true, 54 71 } 55 72 s.mu.Unlock() 56 73 } 57 74 58 75 func (s *server) updateDoc(u uri.URI, version int32, changes []protocol.TextDocumentContentChangeEvent) { 59 76 s.mu.Lock() 60 - defer s.mu.Unlock() 61 77 state, ok := s.openDocs[u] 62 78 if !ok { 79 + s.mu.Unlock() 63 80 return 64 81 } 65 82 state.version = version ··· 73 90 _ = ev // TODO: incremental edit support 74 91 } 75 92 } 93 + state.analysis = nil 94 + state.dirty = true 76 95 s.openDocs[u] = state 96 + s.mu.Unlock() 97 + 98 + s.markDependentsDirty(u) 99 +} 100 + 101 +// markDependentsDirty dirties every open doc whose cached analysis includes u, 102 +// e.g. after u is edited or closed. 103 +func (s *server) markDependentsDirty(u uri.URI) { 104 + canon := journal.CanonicalPath(u.Path()) 105 + s.mu.Lock() 106 + for du, dstate := range s.openDocs { 107 + if dstate.paths[canon] { 108 + dstate.dirty = true 109 + s.openDocs[du] = dstate 110 + } 111 + } 112 + s.mu.Unlock() 77 113 } 78 114 79 115 func (s *server) getDocState(u uri.URI) (docState, bool) {
M
journal/loader.go
··· 19 19 Path string 20 20 Src []byte 21 21 Ast *ast.Journal 22 - Includes []*ParsedFile 23 22 FileErrors []*ast.FileError 24 23 Errors []*ast.ParseError 25 24 } ··· 35 34 // Resolve/ResolveBytes returns a fresh journal; same source path may appear 36 35 // as multiple occurrences with different parser contexts. 37 36 type ResolvedJournal struct { 38 - Primary *ParsedFile // first occurrence (root file) 39 37 Occurrences []*ParsedFile // all occurrences in depth-first order 40 38 Items []ResolvedItem // flat stream, occur-order 41 39 ByPath map[string][]*ParsedFile ··· 50 48 return all 51 49 } 52 50 51 +// parseCacheMax bounds the number of parsed files retained by the loader. 52 +const parseCacheMax = 64 53 + 54 +type parseKey struct { 55 + canon, content string 56 + defaultYear int 57 +} 58 + 59 +type parseEntry struct { 60 + src []byte 61 + ast *ast.Journal 62 +} 63 + 53 64 // Loader include-aware journal parsing caching. 54 65 type Loader struct { 55 66 mu sync.RWMutex 56 67 contentCache map[string][]byte // canonical path: normalised content 68 + parseCache map[parseKey]parseEntry 69 + 70 + // ContentProvider, when set, is consulted before any disk read. It returns 71 + // the file's authoritative content and ok=true, or ok=false to fall back to 72 + // disk. The zero value (nil) keeps the loader disk-only. 73 + ContentProvider func(path string) ([]byte, bool) 57 74 } 58 75 59 76 func NewLoader() *Loader { 60 - return &Loader{contentCache: make(map[string][]byte)} 77 + return &Loader{ 78 + contentCache: make(map[string][]byte), 79 + parseCache: make(map[parseKey]parseEntry), 80 + } 61 81 } 62 82 63 83 // Resolve performs a fresh include-aware parse of fpath, returning a flat ··· 77 97 ByPath: make(map[string][]*ParsedFile), 78 98 } 79 99 l.resolveOccurrence(rj, nil, fpath, src, 0, nil) 80 - if len(rj.Occurrences) > 0 { 81 - rj.Primary = rj.Occurrences[0] 82 - } 83 100 return rj 84 101 } 85 102 ··· 106 123 } 107 124 108 125 func (l *Loader) remapFilePaths(rj *ResolvedJournal, oldRoot string) { 109 - // remap paths; keep Include field consistent for walkers 110 126 for _, pf := range rj.Occurrences { 111 127 if rel, err := filepath.Rel(oldRoot, pf.Path); err == nil { 112 128 pf.Path = filepath.ToSlash(rel) ··· 127 143 128 144 // InvalidateFile removes a file from the content cache 129 145 func (l *Loader) InvalidateFile(fpath string) { 130 - canon := canonicalPath(fpath) 146 + canon := CanonicalPath(fpath) 131 147 l.mu.Lock() 132 148 delete(l.contentCache, canon) 133 149 l.mu.Unlock() 134 150 } 135 151 136 -// ClearCache empties the content cache 137 -func (l *Loader) ClearCache() { 138 - l.mu.Lock() 139 - l.contentCache = make(map[string][]byte) 140 - l.mu.Unlock() 141 -} 152 +// readContent reads a file, preferring the content provider, then the disk content cache. 153 +func (l *Loader) readContent(fpath string) ([]byte, error) { 154 + if l.ContentProvider != nil { 155 + if content, ok := l.ContentProvider(fpath); ok { 156 + return normaliseNewlines(content), nil 157 + } 158 + } 142 159 143 -// readContent reads a file via content cache; normalises line endings and caches. 144 -func (l *Loader) readContent(fpath string) ([]byte, error) { 145 - canon := canonicalPath(fpath) 160 + canon := CanonicalPath(fpath) 146 161 147 162 l.mu.RLock() 148 163 content, ok := l.contentCache[canon] ··· 156 171 return nil, err 157 172 } 158 173 159 - // normalise line endings for consistent caching and parsing 160 - content = bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n")) 161 - content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n")) 162 - 174 + content = normaliseNewlines(raw) 163 175 l.mu.Lock() 164 176 l.contentCache[canon] = content 165 177 l.mu.Unlock() 166 178 return content, nil 167 179 } 168 180 181 +func normaliseNewlines(raw []byte) []byte { 182 + content := bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n")) 183 + return bytes.ReplaceAll(content, []byte("\r"), []byte("\n")) 184 +} 185 + 169 186 // resolveOccurrence recursively parses one occurrence and its includes 170 187 func (l *Loader) resolveOccurrence(rj *ResolvedJournal, parent *ParsedFile, fpath string, src []byte, defaultYear int, stack []string) { 171 188 // cycle detection uses canonical paths to catch cycles through symlinks. 172 - canon := canonicalPath(fpath) 189 + canon := CanonicalPath(fpath) 173 190 if slices.Contains(stack, canon) { 174 191 if parent != nil { 175 192 parent.FileErrors = append(parent.FileErrors, &ast.FileError{ ··· 180 197 return 181 198 } 182 199 183 - lex := lexer.New(fpath, src) 184 - par := parser.NewWithYear(lex, defaultYear) 185 - j := par.ParseJournal() 200 + key := parseKey{canon: canon, content: string(src), defaultYear: defaultYear} 201 + entry, ok := l.parseLookup(key) 202 + if !ok { 203 + lex := lexer.New(fpath, src) 204 + par := parser.NewWithYear(lex, defaultYear) 205 + j := par.ParseJournal() 206 + entry = parseEntry{src: src, ast: j} 207 + l.parseStore(key, entry) 208 + } 186 209 187 210 pf := &ParsedFile{ 188 - Path: fpath, 189 - Src: src, 190 - Ast: j, 191 - Includes: []*ParsedFile{}, 192 - Errors: j.Errors, 211 + Path: fpath, 212 + Src: entry.src, 213 + Ast: entry.ast, 214 + Errors: entry.ast.Errors, 193 215 } 194 216 rj.Occurrences = append(rj.Occurrences, pf) 195 217 rj.ByPath[fpath] = append(rj.ByPath[fpath], pf) 196 218 197 - if parent != nil { 198 - parent.Includes = append(parent.Includes, pf) 199 - } 200 - 201 219 currentYear := defaultYear 202 - for i, entry := range j.Entries { 220 + for i, entry := range entry.ast.Entries { 203 221 switch e := entry.(type) { 204 222 case *ast.BlankLine: 205 223 continue ··· 270 288 return target, nil 271 289 } 272 290 273 -func canonicalPath(path string) string { 291 +// CanonicalPath resolvea path to it's canonical form: absolute, symlinks evaluated, cleaned. 292 +func CanonicalPath(path string) string { 274 293 abs, err := filepath.Abs(path) 275 294 if err != nil { 276 295 return filepath.Clean(path) ··· 281 300 } 282 301 return filepath.Clean(canonical) 283 302 } 303 + 304 +func (l *Loader) parseLookup(key parseKey) (parseEntry, bool) { 305 + l.mu.RLock() 306 + entry, ok := l.parseCache[key] 307 + l.mu.RUnlock() 308 + return entry, ok 309 +} 310 + 311 +func (l *Loader) parseStore(key parseKey, entry parseEntry) { 312 + l.mu.Lock() 313 + l.parseCache[key] = entry 314 + if len(l.parseCache) > parseCacheMax { 315 + for k := range l.parseCache { 316 + delete(l.parseCache, k) 317 + break 318 + } 319 + } 320 + l.mu.Unlock() 321 +}
M
journal/loader_test.go
··· 4 4 "fmt" 5 5 "os" 6 6 "path/filepath" 7 + "strings" 7 8 "testing" 8 9 10 + "olexsmir.xyz/clerk/internal/testutil" 11 + "olexsmir.xyz/clerk/internal/testutil/golden" 9 12 "olexsmir.xyz/clerk/internal/testutil/txtar" 10 13 "olexsmir.xyz/clerk/journal/ast" 11 14 ) 12 15 13 -func TestLoader_Resolve_basic(t *testing.T) { 14 - rj := resolveTxtar(t, "root.journal", ` 15 --- root.journal -- 16 -2024/01/01 t 17 - a $1 18 -`) 19 - if len(rj.Occurrences) != 1 || len(rj.Items) != 1 || rj.Items[0].IsInclude { 20 - t.Fatal("basic: expected 1 file, 1 non-include item") 21 - } 22 -} 23 - 24 -func TestLoader_Resolve_withInclude(t *testing.T) { 25 - rj := resolveTxtar(t, "parent.journal", ` 26 --- parent.journal -- 27 -include child.journal 28 -2024/01/01 t 29 - a $1 30 - 31 --- child.journal -- 32 -account expenses:food 33 -2024/06/15 lunch 34 - expenses:food $5 35 - assets:cash 36 -`) 37 - // 2 files: parent (include + tx), child (directive + tx) = 4 items 38 - if len(rj.Items) != 4 { 39 - t.Fatalf("expected 4 items, got %d", len(rj.Items)) 40 - } 41 - if !rj.Items[0].IsInclude { 42 - t.Fatal("items[0] should be include marker") 43 - } 44 - if len(rj.Occurrences) != 2 { 45 - t.Fatal("expected 2 occurrences") 46 - } 47 -} 48 - 49 -func TestLoader_Resolve_yearPropagation(t *testing.T) { 50 - rj := resolveTxtar(t, "parent.journal", ` 51 --- parent.journal -- 52 -year 2025 53 -include child.journal 54 -12-01 dinner 55 - expenses:food $10 56 - assets:cash 57 - 58 --- child.journal -- 59 -06-15 lunch 60 - expenses:food $5 61 - assets:cash 62 -`) 63 - dates := make([]string, 0, 2) 64 - for _, item := range rj.Items { 65 - if item.IsInclude { 66 - continue 67 - } 68 - tx, ok := item.Occurrence.Ast.Entries[item.EntryIndex].(*ast.Transaction) 69 - if !ok { 70 - continue 71 - } 72 - dates = append(dates, fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)) 73 - } 74 - if len(dates) != 2 || dates[0] != "2025-06-15" || dates[1] != "2025-12-01" { 75 - t.Fatalf("expected [2025-06-15 2025-12-01], got %v", dates) 16 +func TestLoader_Resolve(t *testing.T) { 17 + for _, tname := range []string{"basic", "with-include", "year-propagation", "year-context", "repeated-include", "parse-errors"} { 18 + t.Run(tname, func(t *testing.T) { 19 + a := golden.Read(t, tname) 20 + fsys, err := a.FS() 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + rj, err := NewLoader().ResolveFS(fsys, "in.journal") 25 + if err != nil { 26 + t.Fatalf("resolving in.journal: %v", err) 27 + } 28 + golden.Assert(t, a, dumpResolved(rj)) 29 + }) 76 30 } 77 31 } 78 32 ··· 89 43 } 90 44 } 91 45 92 -func TestLoader_Resolve_repeatedInclude(t *testing.T) { 93 - rj := resolveTxtar(t, "parent.journal", ` 94 --- parent.journal -- 95 -include shared.journal 96 -include shared.journal 97 - 98 --- shared.journal -- 99 -account expenses:shared 100 -`) 101 - if len(rj.Occurrences) != 3 { 102 - t.Fatalf("expected 3 occurrences, got %d", len(rj.Occurrences)) 103 - } 104 - if len(rj.ByPath["shared.journal"]) != 2 { 105 - t.Fatalf("expected 2 ByPath entries for shared.journal, got %d", 106 - len(rj.ByPath["shared.journal"])) 46 +func dumpResolved(rj *ResolvedJournal) string { 47 + var b strings.Builder 48 + for _, pf := range rj.Occurrences { 49 + fmt.Fprintf(&b, "== %s ==\n", pf.Path) 50 + for i, e := range pf.Ast.Entries { 51 + if s := entrySummary(e); s != "" { 52 + fmt.Fprintf(&b, " %d %s\n", i+1, s) 53 + } 54 + } 55 + for _, fe := range pf.FileErrors { 56 + fmt.Fprintf(&b, " error: %s\n", fe.Message) 57 + } 58 + for _, pe := range pf.Errors { 59 + fmt.Fprintf(&b, " parse-error: %s\n", pe.Message) 60 + } 107 61 } 62 + return b.String() 108 63 } 109 64 110 -func TestLoader_ResolveBytes_parseErrors(t *testing.T) { 111 - rj := NewLoader().ResolveBytes("bad.journal", []byte("@@@ garbage\n")) 112 - if len(rj.Occurrences[0].Errors) == 0 { 113 - t.Fatal("expected parse errors for garbage input") 65 +func entrySummary(e ast.Entry) string { 66 + switch e := e.(type) { 67 + case *ast.BlankLine: 68 + return "" 69 + case *ast.IncludeDirective: 70 + return "include " + e.Path 71 + case *ast.YearDirective: 72 + return fmt.Sprintf("year %d", e.Year) 73 + case *ast.AccountDirective: 74 + return "account " + e.Account.String() 75 + case *ast.Transaction: 76 + s := fmt.Sprintf("%04d-%02d-%02d", e.Date.Year, e.Date.Month, e.Date.Day) 77 + if e.Payee != nil { 78 + s += " " + e.Payee.Name 79 + } 80 + return s 81 + default: 82 + return fmt.Sprintf("<%T>", e) 114 83 } 115 84 } 116 85 ··· 151 120 } 152 121 } 153 122 154 -func TestLoader_ClearCache(t *testing.T) { 155 - l := NewLoader() 156 - if len(l.contentCache) != 0 { 157 - t.Fatal("expected empty cache") 158 - } 159 - l.contentCache["x"] = []byte("y") 160 - l.ClearCache() 161 - if len(l.contentCache) != 0 { 162 - t.Fatal("expected empty cache after clear") 163 - } 164 -} 165 - 166 123 func TestResolveIncludePath(t *testing.T) { 167 124 tests := []struct { 168 125 parent, pattern string ··· 219 176 tx := rj.Occurrences[0].Ast.Entries[0].(*ast.Transaction) 220 177 return fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day) 221 178 } 179 + 180 +// writeFiles writes the given files into dir. 181 +func writeFiles(t *testing.T, dir string, files map[string]string) { 182 + t.Helper() 183 + for name, content := range files { 184 + testutil.WriteFile(t, filepath.Join(dir, name), []byte(content)) 185 + } 186 +} 187 + 188 +// newLoaderDir writes files into a temp dir and returns a loader over it. 189 +func newLoaderDir(t *testing.T, files map[string]string) (*Loader, string) { 190 + t.Helper() 191 + dir := t.TempDir() 192 + writeFiles(t, dir, files) 193 + return NewLoader(), dir 194 +} 195 + 196 +// mustOccurrence returns the occurrence of path in the resolve. 197 +func mustOccurrence(t *testing.T, rj *ResolvedJournal, path string) *ParsedFile { 198 + t.Helper() 199 + got := rj.ByPath[path] 200 + if len(got) == 0 { 201 + t.Fatalf("file not resolved: %s", path) 202 + } 203 + return got[0] 204 +} 205 + 206 +// TestLoader_ParseCache_SharedPointer: two roots including the same file get 207 +// the same *ast.Journal for it (one parse, shared). 208 +func TestLoader_ParseCache_SharedPointer(t *testing.T) { 209 + l, dir := newLoaderDir(t, map[string]string{ 210 + "a.journal": "include c.journal\n", 211 + "b.journal": "include c.journal\n", 212 + "c.journal": "account expenses:food\n", 213 + }) 214 + c := filepath.Join(dir, "c.journal") 215 + 216 + ca := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "a.journal")), c) 217 + cb := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "b.journal")), c) 218 + if ca.Ast != cb.Ast { 219 + t.Error("c.journal parsed twice: Ast pointers differ") 220 + } 221 + if ca == cb { 222 + t.Error("ParsedFile wrapper must be per-resolve") 223 + } 224 +} 225 + 226 +// TestLoader_ParseCache_ContentChange: new content forces a new parse. 227 +func TestLoader_ParseCache_ContentChange(t *testing.T) { 228 + l, dir := newLoaderDir(t, map[string]string{ 229 + "a.journal": "include c.journal\n", 230 + "c.journal": "account expenses:food\n", 231 + }) 232 + a, c := filepath.Join(dir, "a.journal"), filepath.Join(dir, "c.journal") 233 + 234 + first := mustOccurrence(t, mustResolve(t, l, a), c).Ast 235 + writeFiles(t, dir, map[string]string{"c.journal": "account expenses:travel\n"}) 236 + l.InvalidateFile(c) // the content cache would serve stale bytes 237 + second := mustOccurrence(t, mustResolve(t, l, a), c).Ast 238 + if first == second { 239 + t.Error("edited file served the old parse") 240 + } 241 +} 242 + 243 +// TestLoader_ContentProvider: open-buffer text wins over disk for includes. 244 +func TestLoader_ContentProvider(t *testing.T) { 245 + l, dir := newLoaderDir(t, map[string]string{ 246 + "a.journal": "include c.journal\n", 247 + "c.journal": "account expenses:old\n", 248 + }) 249 + l.ContentProvider = func(path string) ([]byte, bool) { 250 + if filepath.Base(path) == "c.journal" { 251 + return []byte("account expenses:new\n"), true 252 + } 253 + return nil, false 254 + } 255 + 256 + pf := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "a.journal")), filepath.Join(dir, "c.journal")) 257 + ad := pf.Ast.Entries[0].(*ast.AccountDirective) 258 + if ad.Account.String() != "expenses:new" { 259 + t.Errorf("included content = %q, want expenses:new (buffer wins over disk)", ad.Account.String()) 260 + } 261 +}
A
journal/testdata/repeated-include.txtar
··· 1 +-- in.journal -- 2 +include shared.journal 3 +include shared.journal 4 + 5 +-- shared.journal -- 6 +account expenses:shared 7 + 8 +-- expect -- 9 +== in.journal == 10 + 1 include shared.journal 11 + 2 include shared.journal 12 +== shared.journal == 13 + 1 account expenses:shared 14 +== shared.journal == 15 + 1 account expenses:shared
A
journal/testdata/with-include.txtar
··· 1 +-- in.journal -- 2 +include child.journal 3 +2024/01/01 t 4 + a $1 5 + 6 +-- child.journal -- 7 +account expenses:food 8 +2024/06/15 lunch 9 + expenses:food $5 10 + assets:cash 11 + 12 +-- expect -- 13 +== in.journal == 14 + 1 include child.journal 15 + 2 2024-01-01 t 16 +== child.journal == 17 + 1 account expenses:food 18 + 2 2024-06-15 lunch
A
journal/testdata/year-context.txtar
··· 1 +-- in.journal -- 2 +include a.journal 3 +include b.journal 4 + 5 +-- a.journal -- 6 +year 2025 7 +include c.journal 8 + 9 +-- b.journal -- 10 +year 2030 11 +include c.journal 12 + 13 +-- c.journal -- 14 +06-15 lunch 15 + expenses:food $5 16 + assets:cash 17 + 18 +-- expect -- 19 +== in.journal == 20 + 1 include a.journal 21 + 2 include b.journal 22 +== a.journal == 23 + 1 year 2025 24 + 2 include c.journal 25 +== c.journal == 26 + 1 2025-06-15 lunch 27 +== b.journal == 28 + 1 year 2030 29 + 2 include c.journal 30 +== c.journal == 31 + 1 2030-06-15 lunch
A
journal/testdata/year-propagation.txtar
··· 1 +-- in.journal -- 2 +year 2025 3 +include child.journal 4 +12-01 dinner 5 + expenses:food $10 6 + assets:cash 7 + 8 +-- child.journal -- 9 +06-15 lunch 10 + expenses:food $5 11 + assets:cash 12 + 13 +-- expect -- 14 +== in.journal == 15 + 1 year 2025 16 + 2 include child.journal 17 + 3 2025-12-01 dinner 18 +== child.journal == 19 + 1 2025-06-15 lunch