master...lsp
6 ahead,
8 behind
Commits in lsp not in master
17 files changed,
947 insertions(+),
14 deletions(-)
| Message | Author | Hash | Age |
|---|---|---|---|
| lsp: completion |
Oleksandr Smirnov olexsmir@gmail.com |
6106d58 | 19 days ago 2026-07-11 12:58:15 +0300 |
| linter/lsp: scope checks to include tree |
Oleksandr Smirnov olexsmir@gmail.com |
9369172 | 19 days ago 2026-07-11 12:58:15 +0300 |
| lsp: diagnostics |
Oleksandr Smirnov olexsmir@gmail.com |
225a2d3 | 19 days ago 2026-07-11 12:58:15 +0300 |
| lsp: format |
Oleksandr Smirnov olexsmir@gmail.com |
8e87a98 | 19 days ago 2026-07-11 12:58:15 +0300 |
| lsp server |
Oleksandr Smirnov olexsmir@gmail.com |
f9a2588 | 19 days ago 2026-07-11 12:58:15 +0300 |
| add AGENTS.md |
Oleksandr Smirnov olexsmir@gmail.com |
e62c721 | 19 days ago 2026-07-11 12:51:53 +0300 |
jump to
A
AGENTS.md
··· 1 +# clerk — plain-text accounting toolchain 2 + 3 +Go toolkit for ledger/hledger journal files: parsing, linting, formatting, LSP, tags. 4 + 5 +## Commands 6 +```bash 7 +go build -ldflags="-X 'main.version=dev'" . # build 8 +go test ./... # all tests 9 +go test ./internal/linter/ -run TestLinter -v # linter golden suite 10 +``` 11 + 12 +## Conventions 13 +- **Golden tests:** `testdata/<name>.input` (fixture) + `<name>.golden` (expected), use `internal/testutil/golden.Load/Assert` 14 +- **semantic.Build(files):** files must be in include-order (includers after includes) 15 + 16 +## Boundaries 17 +- Use `internal/decimal.Decimal` for monetary amounts (no float64) 18 +- Don't reorder files passed to `semantic.Build` 19 + 20 +## Verification 21 +```bash 22 +go build ./... && go test ./... 23 +```
M
go.mod
··· 4 4 5 5 tool golang.org/x/tools/cmd/stringer 6 6 7 -require github.com/urfave/cli/v3 v3.10.0 7 +require ( 8 + github.com/urfave/cli/v3 v3.10.0 9 + go.lsp.dev/jsonrpc2 v1.0.1 10 + go.lsp.dev/protocol v1.0.1 11 +) 8 12 9 13 require ( 14 + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect 15 + go.lsp.dev/uri v1.0.1 // indirect 10 16 golang.org/x/mod v0.36.0 // indirect 11 17 golang.org/x/sync v0.20.0 // indirect 12 18 golang.org/x/tools v0.45.0 // indirect
M
go.sum
··· 1 1 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 2 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 4 -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 3 +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= 4 +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= 5 +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 5 7 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 8 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 9 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 8 10 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 9 11 github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM= 10 12 github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 13 +go.lsp.dev/jsonrpc2 v1.0.1 h1:JZAchpm6RcOuwEOnZ7kF3kA1EpEjdJZ/6rMSfOF3+F4= 14 +go.lsp.dev/jsonrpc2 v1.0.1/go.mod h1:WN+TesqrPumBmUdsuWyn9DHq3zl4i85L/qG0NLdj8qg= 15 +go.lsp.dev/protocol v1.0.1 h1:kLWUIk1v/deXxo1D0VO4avjZnfeuZ82mouJgJd/NbdU= 16 +go.lsp.dev/protocol v1.0.1/go.mod h1:e//i41aEqc9ml32xiNuneUwbjOjuvTA4+uv+AkssJgk= 17 +go.lsp.dev/uri v1.0.1 h1:ywxJN1UXoQ+v0I9lkoQNMmsm7lub5G9ZG7KXT8YTx30= 18 +go.lsp.dev/uri v1.0.1/go.mod h1:06a0ghafs4PuqkLsJGUmlgPHw6+kH79edRXjOT/rA8s= 11 19 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= 12 20 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 13 21 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
M
internal/cli/cmd_lint.go
··· 43 43 } 44 44 } 45 45 46 - sema := semantic.Build(loader.Ordered()) 47 - reporter.Collect(lint.Run(sema)) 46 + for _, root := range loader.Roots() { 47 + reporter.Collect(lint.Run(semantic.Build(journal.CollectFiles(root)))) 48 + } 48 49 49 50 if err := reporter.Flush(format); err != nil { 50 51 return fmt.Errorf("flushing report: %w", err) ··· 62 63 return err 63 64 } 64 65 65 - sema := semantic.Build(loader.Ordered()) 66 - reporter.Collect(lint.Run(sema)) 66 + for _, root := range loader.Roots() { 67 + reporter.Collect(lint.Run(semantic.Build(journal.CollectFiles(root)))) 68 + } 67 69 68 70 if err := reporter.Flush(format); err != nil { 69 71 return fmt.Errorf("flushing report: %w", err)
A
internal/cli/cmd_lsp.go
··· 1 +package cli 2 + 3 +import ( 4 + "context" 5 + "os" 6 + 7 + "github.com/urfave/cli/v3" 8 + 9 + "olexsmir.xyz/clerk/internal/lsp" 10 +) 11 + 12 +func (c *Cli) lspAction(ctx context.Context, cmd *cli.Command) error { 13 + server := lsp.NewServer(c.version) 14 + 15 + return server.Run(ctx, os.Stdin, os.Stdout) 16 +}
M
internal/linter/linter_test.go
··· 1 1 package linter 2 2 3 3 import ( 4 + "os" 5 + "path/filepath" 4 6 "strings" 5 7 "testing" 6 8 ··· 46 48 } 47 49 } 48 50 51 +func TestLinter_CrossFileScoping(t *testing.T) { 52 + t.Run("undeclared across sibling trees", func(t *testing.T) { 53 + loader := loadTestFiles(t, map[string]string{ 54 + "root_a.journal": "2026/01/01 * \"A\"\n Expenses:Food $10\n Assets:Cash\n", 55 + "root_b.journal": "account Expenses:Food\n", 56 + }) 57 + finds := runLint(t, loader) 58 + 59 + n := 0 60 + for _, f := range finds { 61 + if f.Code == "undeclared-account" { 62 + n++ 63 + } 64 + } 65 + if n != 2 { 66 + t.Fatalf("expected 2 undeclared (Expenses:Food + Assets:Cash in root_a), got %d", n) 67 + } 68 + }) 69 + 70 + t.Run("declaration in included file visible", func(t *testing.T) { 71 + loader := loadTestFiles(t, map[string]string{ 72 + "child.journal": "account Expenses:Food\n", 73 + "parent.journal": "include child.journal\n\n2026/01/01 * \"Lunch\"\n Expenses:Food $10\n Assets:Cash\n", 74 + }) 75 + finds := runLint(t, loader) 76 + 77 + n := 0 78 + for _, f := range finds { 79 + if f.Code == "undeclared-account" { 80 + n++ 81 + } 82 + } 83 + if n != 1 { 84 + t.Fatalf("expected 1 undeclared (Assets:Cash), got %d", n) 85 + } 86 + }) 87 + 88 + t.Run("duplicate across sibling trees not flagged", func(t *testing.T) { 89 + loader := loadTestFiles(t, map[string]string{ 90 + "a.journal": "account Expenses:Food\n", 91 + "b.journal": "account Expenses:Food\n", 92 + }) 93 + finds := runLint(t, loader) 94 + 95 + for _, f := range finds { 96 + if f.Code == "duplicated-account" { 97 + t.Fatalf("unexpected duplicate across trees: %v", f) 98 + } 99 + } 100 + }) 101 +} 102 + 49 103 func BenchmarkLinter(b *testing.B) { 50 104 ldr := journal.NewLoader() 51 105 _, err := ldr.Load( ··· 64 118 l.Run(ctx) 65 119 } 66 120 } 121 + 122 +func loadTestFiles(t testing.TB, files map[string]string) *journal.Loader { 123 + t.Helper() 124 + dir := t.TempDir() 125 + loader := journal.NewLoader() 126 + for name, content := range files { 127 + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { 128 + t.Fatal(err) 129 + } 130 + if _, err := loader.Load(filepath.Join(dir, name)); err != nil { 131 + t.Fatal(err) 132 + } 133 + } 134 + return loader 135 +} 136 + 137 +func runLint(t testing.TB, loader *journal.Loader) []Find { 138 + t.Helper() 139 + lint := NewLinter(Rules) 140 + var finds []Find 141 + for _, root := range loader.Roots() { 142 + finds = append(finds, lint.Run(semantic.Build(journal.CollectFiles(root)))...) 143 + } 144 + return finds 145 +}
A
internal/lsp/completion.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "slices" 6 + "strings" 7 + 8 + "go.lsp.dev/protocol" 9 + 10 + "olexsmir.xyz/clerk/journal/semantic" 11 +) 12 + 13 +func (s *server) Completion(ctx context.Context, params *protocol.CompletionParams) (protocol.CompletionResult, error) { 14 + text, ok := s.getDocText(params.TextDocument.URI) 15 + if !ok { 16 + return &protocol.CompletionList{IsIncomplete: false}, nil 17 + } 18 + 19 + lines := strings.Split(text, "\n") 20 + lineIdx := int(params.Position.Line) 21 + if lineIdx >= len(lines) { 22 + lineIdx = len(lines) - 1 23 + } 24 + if lineIdx < 0 { 25 + lineIdx = 0 26 + } 27 + 28 + currentLine := lines[lineIdx] 29 + col := min(params.Position.Character, uint32(len(currentLine))) 30 + 31 + prefix := currentLine[:col] 32 + kind, word := detectKind(prefix) 33 + 34 + s.semaMu.RLock() 35 + sema := s.semanticCtx 36 + s.semaMu.RUnlock() 37 + 38 + var items []protocol.CompletionItem 39 + switch kind { 40 + case complAccount: 41 + items = completeAccounts(sema, word) 42 + case complPayee: 43 + items = completePayees(sema, word) 44 + case complCommodity: 45 + items = completeCommodities(sema, word) 46 + case complKeyword: 47 + items = completeKeywords(word) 48 + } 49 + if items == nil { 50 + items = []protocol.CompletionItem{} 51 + } 52 + 53 + return &protocol.CompletionList{ 54 + IsIncomplete: false, 55 + Items: items, 56 + }, nil 57 +} 58 + 59 +type completionKind int 60 + 61 +const ( 62 + complNone completionKind = iota 63 + complAccount 64 + complPayee 65 + complCommodity 66 + complKeyword 67 +) 68 + 69 +func detectKind(prefix string) (completionKind, string) { 70 + if prefix == "" { 71 + return complNone, "" 72 + } 73 + 74 + trimmed := strings.TrimLeft(prefix, " \t") 75 + if trimmed == "" { 76 + return complNone, "" 77 + } 78 + 79 + c := prefix[0] 80 + 81 + // posting line: starts with indentation 82 + if c == ' ' || c == '\t' { 83 + content := strings.TrimLeft(trimmed, "!* ([") 84 + if _, after, ok := strings.Cut(content, " "); ok { 85 + // after := strings.TrimLeft(after, " \t") 86 + if n := strings.LastIndexAny(after, " \t"); n >= 0 { // last field (after whitespace) 87 + return complCommodity, after[n+1:] 88 + } 89 + return complCommodity, after 90 + } 91 + return complAccount, content 92 + } 93 + 94 + // directive keywords at line start 95 + if kw, rest := matchKeyword(trimmed); kw != "" { 96 + switch kw { 97 + case "account", "alias": 98 + return complAccount, rest 99 + case "payee": 100 + return complPayee, rest 101 + case "commodity", "D", "P", "N": 102 + return complCommodity, rest 103 + } 104 + return complNone, "" 105 + } 106 + 107 + // after date, transaction header 108 + if c >= '0' && c <= '9' { 109 + return complPayee, skipTransactionPreamble(trimmed) 110 + } 111 + 112 + // partial keyword on unknown line 113 + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { 114 + return complKeyword, trimmed 115 + } 116 + 117 + return complNone, "" 118 +} 119 + 120 +func matchKeyword(trimmed string) (keyword, rest string) { 121 + i := 0 122 + for i < len(trimmed) && trimmed[i] != ' ' && trimmed[i] != '\t' { 123 + i++ 124 + } 125 + if i == 0 { 126 + return "", "" 127 + } 128 + word := trimmed[:i] 129 + switch word { 130 + case "account", "alias", "payee", "commodity", 131 + "include", "tag", "apply", "end", 132 + "year", "Y", "decimal-mark", 133 + "D", "P", "N", "C", "comment": 134 + default: 135 + return "", "" 136 + } 137 + rest = strings.TrimLeft(trimmed[i:], " \t") 138 + return word, rest 139 +} 140 + 141 +func skipTransactionPreamble(trimmed string) string { 142 + // Skip date (digits and date separators) 143 + i := 0 144 + for i < len(trimmed) { 145 + c := trimmed[i] 146 + if (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '.' { 147 + i++ 148 + } else { 149 + break 150 + } 151 + } 152 + after := trimmed[i:] 153 + 154 + // Skip whitespace, status markers, and code in parens 155 + for { 156 + before := after 157 + after = strings.TrimLeft(after, " \t") 158 + if len(after) > 0 && (after[0] == '*' || after[0] == '!') { 159 + after = after[1:] 160 + continue 161 + } 162 + if len(after) > 0 && after[0] == '(' { 163 + end := strings.IndexByte(after, ')') 164 + if end >= 0 { 165 + after = after[end+1:] 166 + continue 167 + } 168 + } 169 + if after == before { 170 + break 171 + } 172 + } 173 + return after 174 +} 175 + 176 +func completeAccounts(sema *semantic.Context, prefix string) []protocol.CompletionItem { 177 + if sema == nil || prefix == "" { 178 + return nil 179 + } 180 + var items []protocol.CompletionItem 181 + for name := range sema.Accounts { 182 + if strings.HasPrefix(name, prefix) { 183 + items = append(items, protocol.CompletionItem{ 184 + Label: name, 185 + Kind: protocol.CompletionItemKindVariable, 186 + // Detail: protocol.NewOptional("account"), 187 + }) 188 + } 189 + } 190 + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { 191 + return strings.Compare(a.Label, b.Label) 192 + }) 193 + return items 194 +} 195 + 196 +func completePayees(sema *semantic.Context, prefix string) []protocol.CompletionItem { 197 + if sema == nil { 198 + return nil 199 + } 200 + var items []protocol.CompletionItem 201 + for name := range sema.Payees { 202 + if prefix != "" && !strings.HasPrefix(name, prefix) { 203 + continue 204 + } 205 + items = append(items, protocol.CompletionItem{ 206 + Label: name, 207 + Kind: protocol.CompletionItemKindText, 208 + // Detail: protocol.NewOptional("payee"), 209 + }) 210 + } 211 + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { 212 + return strings.Compare(a.Label, b.Label) 213 + }) 214 + return items 215 +} 216 + 217 +func completeCommodities(sema *semantic.Context, prefix string) []protocol.CompletionItem { 218 + if sema == nil { 219 + return nil 220 + } 221 + var items []protocol.CompletionItem 222 + for name := range sema.Commodities { 223 + if prefix != "" && !strings.HasPrefix(name, prefix) { 224 + continue 225 + } 226 + items = append(items, protocol.CompletionItem{ 227 + Label: name, 228 + Kind: protocol.CompletionItemKindConstant, 229 + // Detail: protocol.NewOptional("commodity"), 230 + }) 231 + } 232 + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { 233 + return strings.Compare(a.Label, b.Label) 234 + }) 235 + return items 236 +} 237 + 238 +// keywords 239 + 240 +type keywordInfo struct { 241 + label, detail string 242 +} 243 + 244 +var directiveKeywords = []keywordInfo{ 245 + {"account", "define an account"}, 246 + {"alias", "alias an account name"}, 247 + {"apply", "apply a directive"}, 248 + {"comment", "comment block"}, 249 + {"commodity", "define a commodity"}, 250 + {"decimal-mark", "set decimal mark"}, 251 + {"end", "end a scoped directive"}, 252 + {"include", "include another file"}, 253 + {"payee", "define a payee"}, 254 + {"tag", "define a tag"}, 255 + {"year", "set default year"}, 256 + {"C", "commodity conversion"}, 257 + {"D", "set default commodity"}, 258 + {"N", "ignored price"}, 259 + {"P", "market price"}, 260 + {"Y", "set default year"}, 261 +} 262 + 263 +func completeKeywords(prefix string) []protocol.CompletionItem { 264 + var items []protocol.CompletionItem 265 + for _, kw := range directiveKeywords { 266 + if !strings.HasPrefix(kw.label, prefix) { 267 + continue 268 + } 269 + items = append(items, protocol.CompletionItem{ 270 + Label: kw.label, 271 + Kind: protocol.CompletionItemKindKeyword, 272 + Detail: protocol.NewOptional(kw.detail), 273 + }) 274 + } 275 + return items 276 +}
A
internal/lsp/diagnostics.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "go.lsp.dev/jsonrpc2" 9 + "go.lsp.dev/protocol" 10 + "go.lsp.dev/uri" 11 + 12 + "olexsmir.xyz/clerk/internal/linter" 13 + "olexsmir.xyz/clerk/journal" 14 + "olexsmir.xyz/clerk/journal/semantic" 15 + "olexsmir.xyz/clerk/journal/token" 16 +) 17 + 18 +func (s *server) rebuildWorkspace(ctx context.Context) { 19 + s.logger.Debug("rebuilding workspace") 20 + 21 + s.mu.Lock() 22 + defer s.mu.Unlock() 23 + 24 + if len(s.openDocs) == 0 { 25 + return 26 + } 27 + 28 + // reload all open docs from in-memory content 29 + for duri, state := range s.openDocs { 30 + path := duri.Path() 31 + if _, err := s.loader.Reload(path, []byte(state.text)); err != nil { 32 + s.logger.Warn("failed to relod open document", "uri", duri, "err", err) 33 + } 34 + } 35 + 36 + roots := s.loader.Roots() 37 + if len(roots) == 0 { 38 + s.logger.Debug("no files in workspace") 39 + return 40 + } 41 + 42 + var allFiles []*journal.ParsedFile 43 + var finds []linter.Find 44 + for _, root := range roots { 45 + files := journal.CollectFiles(root) 46 + c := semantic.Build(files) 47 + finds = append(finds, s.linter.Run(c)...) 48 + allFiles = append(allFiles, files...) 49 + } 50 + finds = dedupFinds(finds) 51 + 52 + s.semaMu.Lock() 53 + s.semanticCtx = semantic.Build(allFiles) 54 + s.semaMu.Unlock() 55 + 56 + diagsByFile := groupFindsByFile(finds) 57 + activeFiles := activeFileSet(s.openDocs, s.loader.Ordered()) 58 + for fpath := range activeFiles { 59 + diags := diagsByFile[fpath] 60 + if diags == nil { // TODO: not sure if it's needed 61 + diags = []protocol.Diagnostic{} 62 + } 63 + if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ 64 + URI: uri.File(fpath), 65 + Diagnostics: diags, 66 + }); err != nil { 67 + s.logger.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err) 68 + } 69 + } 70 + 71 + s.logger.Debug("workspace rebuild complete", "files", len(s.loader.Ordered()), "findings", len(finds)) 72 +} 73 + 74 +func (s *server) scheduleWorkspaceRebuild(ctx context.Context) { 75 + s.mu.Lock() 76 + if s.debouncer != nil { 77 + s.debouncer.Stop() 78 + } 79 + ctx = jsonrpc2.DetachContext(ctx) 80 + s.debouncer = time.AfterFunc(200*time.Millisecond, func() { 81 + s.rebuildWorkspace(ctx) 82 + }) 83 + s.mu.Unlock() 84 +} 85 + 86 +func (s *server) cancelWorkspaceRebuild() { 87 + s.mu.Lock() 88 + if s.debouncer != nil { 89 + s.debouncer.Stop() 90 + s.debouncer = nil 91 + } 92 + s.mu.Unlock() 93 +} 94 + 95 +func activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool { 96 + active := make(map[string]bool) 97 + for duri := range docs { 98 + active[duri.Path()] = true 99 + } 100 + 101 + visited := make(map[string]bool) 102 + var walk func(*journal.ParsedFile) 103 + walk = func(pf *journal.ParsedFile) { 104 + if visited[pf.Path] { 105 + return 106 + } 107 + active[pf.Path] = true 108 + visited[pf.Path] = true 109 + for _, inc := range pf.Includes { 110 + walk(inc) 111 + } 112 + } 113 + 114 + byPath := make(map[string]*journal.ParsedFile, len(ordered)) 115 + for _, pf := range ordered { 116 + byPath[pf.Path] = pf 117 + } 118 + for duri := range docs { 119 + if pf, ok := byPath[duri.Path()]; ok { 120 + walk(pf) 121 + } 122 + } 123 + 124 + return active 125 +} 126 + 127 +func groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic { 128 + diags := make(map[string][]protocol.Diagnostic) 129 + for _, find := range finds { 130 + file := find.Span.Start.File 131 + if file == "" { 132 + continue 133 + } 134 + diags[file] = append(diags[file], findToDiagnostic(find)) 135 + } 136 + return diags 137 +} 138 + 139 +func dedupFinds(finds []linter.Find) []linter.Find { 140 + seen := make(map[string]bool) 141 + dedup := make([]linter.Find, 0, len(finds)) 142 + for _, f := range finds { 143 + s := f.Span.Start 144 + key := fmt.Sprintf("%s:%d:%d:%s", s.File, s.Line, s.Col, f.Code) 145 + if seen[key] { 146 + continue 147 + } 148 + seen[key] = true 149 + dedup = append(dedup, f) 150 + } 151 + return dedup 152 +} 153 + 154 +func findToDiagnostic(find linter.Find) protocol.Diagnostic { 155 + return protocol.Diagnostic{ 156 + Range: spanToRange(find.Span), 157 + Severity: severityToLSP(find.Severity), 158 + Message: protocol.String(find.Message), 159 + Source: protocol.NewOptional(name), 160 + Code: protocol.String(string(find.Code)), 161 + } 162 +} 163 + 164 +func spanToRange(span token.Span) protocol.Range { 165 + return protocol.Range{ 166 + Start: protocol.Position{ 167 + Line: max(0, uint32(span.Start.Line-1)), 168 + Character: max(0, uint32(span.Start.Col-1)), 169 + }, 170 + End: protocol.Position{ 171 + Line: max(0, uint32(span.End.Line-1)), 172 + Character: uint32(max(0, span.End.Col-1)), 173 + }, 174 + } 175 +} 176 + 177 +func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity { 178 + switch s { 179 + case linter.SeverityError: 180 + return protocol.DiagnosticSeverityError 181 + case linter.SeverityWarning: 182 + return protocol.DiagnosticSeverityWarning 183 + case linter.SeverityInfo: 184 + return protocol.DiagnosticSeverityInformation 185 + case linter.SeverityHint: 186 + return protocol.DiagnosticSeverityHint 187 + default: 188 + panic("impossible diagnostic severity") 189 + } 190 +}
A
internal/lsp/lsp.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "io" 6 + "log/slog" 7 + "os" 8 + 9 + "go.lsp.dev/jsonrpc2" 10 + "go.lsp.dev/protocol" 11 + "go.lsp.dev/uri" 12 + 13 + "olexsmir.xyz/clerk/internal/linter" 14 + "olexsmir.xyz/clerk/journal" 15 + "olexsmir.xyz/clerk/journal/printer" 16 +) 17 + 18 +type Server struct{ server *server } 19 + 20 +func NewServer(version string) *Server { 21 + return &Server{ 22 + server: &server{ 23 + version: version, 24 + openDocs: make(map[uri.URI]docState), 25 + 26 + loader: journal.NewLoader(), 27 + linter: linter.NewLinter(linter.Rules), 28 + printerCfg: printer.DefaultConfig, 29 + 30 + logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{})), 31 + }, 32 + } 33 +} 34 + 35 +func (s *Server) Run(ctx context.Context, stdin io.ReadCloser, stdout io.WriteCloser) error { 36 + stream := jsonrpc2.NewStream(readWriterCloser{ 37 + Reader: stdin, 38 + Writer: stdout, 39 + Closer: stdin, 40 + }) 41 + 42 + _, conn, client := protocol.NewServer(ctx, s.server, stream) 43 + defer conn.Close() 44 + 45 + s.server.client = client 46 + 47 + <-conn.Done() 48 + return conn.Err() 49 +} 50 + 51 +type readWriterCloser struct { 52 + io.Reader 53 + io.Writer 54 + io.Closer 55 +}
A
internal/lsp/server.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "log/slog" 6 + "sync" 7 + "time" 8 + 9 + "go.lsp.dev/protocol" 10 + "go.lsp.dev/uri" 11 + 12 + "olexsmir.xyz/clerk/internal/linter" 13 + "olexsmir.xyz/clerk/journal" 14 + "olexsmir.xyz/clerk/journal/printer" 15 + "olexsmir.xyz/clerk/journal/semantic" 16 +) 17 + 18 +const name = "clerk" 19 + 20 +type docState struct { 21 + text string 22 + version int32 23 + languageID protocol.LanguageKind 24 +} 25 + 26 +type server struct { 27 + protocol.UnimplementedServer 28 + 29 + client protocol.Client 30 + 31 + version string 32 + 33 + printerCfg *printer.Config 34 + logger *slog.Logger 35 + 36 + loader *journal.Loader 37 + linter *linter.Linter 38 + debouncer *time.Timer 39 + 40 + mu sync.Mutex 41 + openDocs map[uri.URI]docState 42 + semanticCtx *semantic.Context 43 + semaMu sync.RWMutex 44 +} 45 + 46 +func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { 47 + return &protocol.InitializeResult{ 48 + ServerInfo: protocol.ServerInfo{ 49 + Name: name, 50 + Version: protocol.NewOptional(s.version), 51 + }, 52 + Capabilities: protocol.ServerCapabilities{ 53 + TextDocumentSync: &protocol.TextDocumentSyncOptions{ 54 + OpenClose: new(true), 55 + Change: new(protocol.TextDocumentSyncKindFull), 56 + }, 57 + DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 58 + CompletionProvider: &protocol.CompletionOptions{ 59 + TriggerCharacters: []string{":", "/"}, 60 + }, 61 + }, 62 + }, nil 63 +} 64 + 65 +func (s *server) Initialized(ctx context.Context, params *protocol.InitializedParams) error { 66 + s.scheduleWorkspaceRebuild(ctx) 67 + return nil 68 +} 69 + 70 +func (s *server) Shutdown(ctx context.Context) error { 71 + s.cancelWorkspaceRebuild() 72 + return nil 73 +} 74 + 75 +func (s *server) Exit(ctx context.Context) error { 76 + return nil 77 +}
A
internal/lsp/textdocument_format.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "go.lsp.dev/protocol" 9 + 10 + "olexsmir.xyz/clerk/journal/lexer" 11 + "olexsmir.xyz/clerk/journal/parser" 12 +) 13 + 14 +func (s *server) Formatting(ctx context.Context, params *protocol.DocumentFormattingParams) ([]protocol.TextEdit, error) { 15 + text, ok := s.getDocText(params.TextDocument.URI) 16 + if !ok { 17 + return nil, nil 18 + } 19 + 20 + lex := lexer.New(params.TextDocument.URI.Path(), []byte(text)) 21 + jnrl := parser.New(lex).ParseJournal() 22 + if len(jnrl.Errors) > 0 { 23 + return nil, fmt.Errorf("can't format file with errors: %v", jnrl.Errors[0].Message) 24 + } 25 + 26 + var buf strings.Builder 27 + if err := s.printerCfg.Fprint(&buf, jnrl); err != nil { 28 + return nil, fmt.Errorf("format: %w", err) 29 + } 30 + newText := buf.String() 31 + 32 + if newText == text { 33 + return nil, nil 34 + } 35 + 36 + endLine, endChar := getEndLineAndChar(text) 37 + return []protocol.TextEdit{{ 38 + NewText: newText, 39 + Range: protocol.Range{ 40 + Start: protocol.Position{ 41 + Line: 0, 42 + Character: 0, 43 + }, 44 + End: protocol.Position{ 45 + Line: uint32(endLine), 46 + Character: uint32(endChar), 47 + }, 48 + }, 49 + }}, nil 50 +} 51 + 52 +func getEndLineAndChar(txt string) (int, int) { 53 + endLine := 0 54 + lastNewline := -1 55 + for i, ch := range txt { 56 + if ch == '\n' { 57 + endLine++ 58 + lastNewline = i 59 + } 60 + } 61 + endChar := 0 62 + if lastNewline >= 0 { 63 + if lastNewline == len(txt)-1 { 64 + endLine++ 65 + endChar = 0 66 + } else { 67 + endChar = len(txt) - lastNewline - 1 68 + } 69 + } else { 70 + endChar = len(txt) 71 + } 72 + return endLine, endChar 73 +}
A
internal/lsp/textdocument_sync.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + 6 + "go.lsp.dev/protocol" 7 + "go.lsp.dev/uri" 8 +) 9 + 10 +func (s *server) DidOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error { 11 + s.openDoc(params.TextDocument.URI, 12 + params.TextDocument.Text, 13 + params.TextDocument.Version, 14 + params.TextDocument.LanguageID) 15 + s.scheduleWorkspaceRebuild(ctx) 16 + return nil 17 +} 18 + 19 +func (s *server) DidChange(ctx context.Context, params *protocol.DidChangeTextDocumentParams) error { 20 + s.updateDoc(params.TextDocument.URI, params.TextDocument.Version, params.ContentChanges) 21 + s.scheduleWorkspaceRebuild(ctx) 22 + return nil 23 +} 24 + 25 +func (s *server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error { 26 + s.closeDoc(params.TextDocument.URI) 27 + s.scheduleWorkspaceRebuild(ctx) 28 + return nil 29 +} 30 + 31 +func (s *server) DidSave(ctx context.Context, params *protocol.DidSaveTextDocumentParams) error { 32 + return nil 33 +} 34 + 35 +// Document management 36 + 37 +func (s *server) openDoc(u uri.URI, text string, version int32, langID protocol.LanguageKind) { 38 + s.mu.Lock() 39 + s.openDocs[u] = docState{ 40 + text: text, 41 + version: version, 42 + languageID: langID, 43 + } 44 + s.mu.Unlock() 45 +} 46 + 47 +func (s *server) updateDoc(u uri.URI, version int32, changes []protocol.TextDocumentContentChangeEvent) { 48 + s.mu.Lock() 49 + defer s.mu.Unlock() 50 + 51 + state, ok := s.openDocs[u] 52 + if !ok { 53 + return 54 + } 55 + state.version = version 56 + for _, ch := range changes { 57 + switch ev := ch.(type) { 58 + case *protocol.TextDocumentContentChangeWholeDocument: 59 + state.text = ev.Text 60 + case *protocol.TextDocumentContentChangePartial: 61 + _ = ev // TODO: incremental edit support 62 + } 63 + } 64 + s.openDocs[u] = state 65 +} 66 + 67 +func (s *server) closeDoc(u uri.URI) { 68 + s.mu.Lock() 69 + delete(s.openDocs, u) 70 + s.mu.Unlock() 71 +} 72 + 73 +func (s *server) getDocText(u uri.URI) (string, bool) { 74 + s.mu.Lock() 75 + state, ok := s.openDocs[u] 76 + s.mu.Unlock() 77 + return state.text, ok 78 +}
M
journal/loader.go
··· 37 37 return l.loadBytes(path, src, nil) 38 38 } 39 39 40 +func (l *Loader) Reload(path string, src []byte) (*ParsedFile, error) { 41 + abs, err := filepath.Abs(path) 42 + if err != nil { 43 + return nil, err 44 + } 45 + delete(l.files, abs) 46 + return l.loadBytes(abs, src, nil) 47 +} 48 + 49 +// Roots returns files not transitively included by any loaded file. 50 +func (l *Loader) Roots() []*ParsedFile { 51 + included := make(map[string]bool) 52 + for _, pf := range l.files { 53 + for _, inc := range pf.Includes { 54 + included[inc.Path] = true 55 + } 56 + } 57 + var roots []*ParsedFile 58 + for _, pf := range l.files { 59 + if !included[pf.Path] { 60 + roots = append(roots, pf) 61 + } 62 + } 63 + return roots 64 +} 65 + 66 +// CollectFiles returns root and all its transitive includes. 67 +func CollectFiles(root *ParsedFile) []*ParsedFile { 68 + var files []*ParsedFile 69 + visited := make(map[*ParsedFile]bool) 70 + var walk func(*ParsedFile) 71 + walk = func(pf *ParsedFile) { 72 + if visited[pf] { 73 + return 74 + } 75 + visited[pf] = true 76 + files = append(files, pf) 77 + for _, inc := range pf.Includes { 78 + walk(inc) 79 + } 80 + } 81 + walk(root) 82 + return files 83 +} 84 + 40 85 // Ordered returns all files in dependency order (included before includer) 41 86 func (l *Loader) Ordered() []*ParsedFile { 42 87 visited := make(map[string]bool)
M
journal/printer/printer.go
··· 34 34 CommodityPos CommodityPos // where to place commodity 35 35 } 36 36 37 -var defaultConfig = &Config{ 37 +var DefaultConfig = &Config{ 38 38 TabIndent: false, 39 39 IndentWidth: 2, 40 40 PreserveBlankLines: false, ··· 63 63 } 64 64 65 65 // Fprint formats using the default config. 66 -func Fprint(w io.Writer, j *ast.Journal) error { return defaultConfig.Fprint(w, j) } 66 +func Fprint(w io.Writer, j *ast.Journal) error { return DefaultConfig.Fprint(w, j) } 67 67 68 68 // Fprint formats a parsed journal. 69 69 func (c *Config) Fprint(w io.Writer, j *ast.Journal) error { 70 70 if c == nil { 71 - c = defaultConfig 71 + c = DefaultConfig 72 72 } 73 73 p := printer{cfg: c, indent: c.indent()} 74 74 ··· 83 83 } 84 84 85 85 // FprintEntry formats a single ast entry using the default config. 86 -func FprintEntry(w io.Writer, e ast.Entry) error { return defaultConfig.FprintEntry(w, e) } 86 +func FprintEntry(w io.Writer, e ast.Entry) error { return DefaultConfig.FprintEntry(w, e) } 87 87 88 88 // FprintEntry formats a single journal entry. 89 89 func (c *Config) FprintEntry(w io.Writer, e ast.Entry) error { 90 90 if c == nil { 91 - c = defaultConfig 91 + c = DefaultConfig 92 92 } 93 93 p := printer{cfg: c, indent: c.indent()} 94 94 p.formatEntry(e)
M
journal/printer/printer_test.go
··· 20 20 t.Fatal(err) 21 21 } 22 22 var b strings.Builder 23 - if err := defaultConfig.Fprint(&b, pf.Ast); err != nil { 23 + if err := DefaultConfig.Fprint(&b, pf.Ast); err != nil { 24 24 t.Fatal(err) 25 25 } 26 26 ··· 41 41 b.ResetTimer() 42 42 for i := 0; i < b.N; i++ { 43 43 var buf strings.Builder 44 - if err := defaultConfig.Fprint(&buf, pf.Ast); err != nil { 44 + if err := DefaultConfig.Fprint(&buf, pf.Ast); err != nil { 45 45 b.Fatal(err) 46 46 } 47 47 }