11 files changed,
464 insertions(+),
7 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-12 13:17:30 +0300
Authored at:
2026-07-11 12:55:38 +0300
Change ID:
lvtwmkztnmkmrpxxqstvyqmonpvktvvn
Parent:
cea9a8c
jump to
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 + go.lsp.dev/uri v1.0.1 12 +) 8 13 9 14 require ( 15 + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // 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 + "olexsmir.xyz/clerk/internal/lsp" 9 +) 10 + 11 +func (c *Cli) lspAction(ctx context.Context, cmd *cli.Command) error { 12 + server := lsp.NewServer(c.version) 13 + return server.Run(ctx, os.Stdin, os.Stdout) 14 +}
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 + "olexsmir.xyz/clerk/internal/linter" 12 + "olexsmir.xyz/clerk/journal" 13 + "olexsmir.xyz/clerk/journal/semantic" 14 + "olexsmir.xyz/clerk/journal/token" 15 +) 16 + 17 +func (s *server) rebuildWorkspace(ctx context.Context) { 18 + s.log.Debug("rebuilding workspace") 19 + 20 + s.mu.Lock() 21 + defer s.mu.Unlock() 22 + 23 + if len(s.openDocs) == 0 { 24 + return 25 + } 26 + 27 + for uri, state := range s.openDocs { 28 + path := uri.Path() 29 + if _, err := s.loader.Reload(path, []byte(state.text)); err != nil { 30 + s.log.Warn("filed to reload open document", "uri", uri, "err", err) 31 + } 32 + } 33 + 34 + roots := s.loader.Roots() 35 + if len(roots) == 0 { 36 + s.log.Debug("no files in workspace") 37 + return 38 + } 39 + 40 + var allFiles []*journal.ParsedFile 41 + var finds []linter.Find 42 + for _, root := range roots { 43 + files := journal.CollectFiles(root) 44 + allFiles = append(allFiles, files...) 45 + c := semantic.Build(files) 46 + finds = append(finds, s.linter.Run(c)...) 47 + 48 + } 49 + 50 + finds = dedupFinds(finds) 51 + s.semanticCtx = semantic.Build(allFiles) 52 + 53 + diagsByFile := s.groupFindsByFile(finds) 54 + activeFiles := s.activeFileSet(s.openDocs, s.loader.Ordered()) 55 + for fpath := range activeFiles { 56 + if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ 57 + URI: uri.File(fpath), 58 + Diagnostics: diagsByFile[fpath], 59 + }); err != nil { 60 + s.log.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err) 61 + } 62 + } 63 + 64 + s.log.Debug("workspace rebuild complete", 65 + "files", len(s.loader.Ordered()), "findings", len(finds)) 66 +} 67 + 68 +func (s *server) scheduleWorkspaceRebuild(ctx context.Context) { 69 + s.mu.Lock() 70 + defer s.mu.Unlock() 71 + if s.debouncer != nil { 72 + s.debouncer.Stop() 73 + } 74 + s.debouncer = time.AfterFunc(200*time.Millisecond, func() { 75 + s.rebuildWorkspace(jsonrpc2.DetachContext(ctx)) 76 + }) 77 +} 78 + 79 +func (s *server) cancelWorkspaceRebuild() { 80 + s.mu.Lock() 81 + defer s.mu.Unlock() 82 + if s.debouncer != nil { 83 + s.debouncer.Stop() 84 + s.debouncer = nil 85 + } 86 +} 87 + 88 +func (s *server) activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool { 89 + active := make(map[string]bool) 90 + for duri := range docs { 91 + active[duri.Path()] = true 92 + } 93 + visited := make(map[string]bool) 94 + var walk func(*journal.ParsedFile) 95 + walk = func(pf *journal.ParsedFile) { 96 + if visited[pf.Path] { 97 + return 98 + } 99 + active[pf.Path] = true 100 + visited[pf.Path] = true 101 + for _, inc := range pf.Includes { 102 + walk(inc) 103 + } 104 + } 105 + byPath := make(map[string]*journal.ParsedFile, len(ordered)) 106 + for _, pf := range ordered { 107 + byPath[pf.Path] = pf 108 + } 109 + for duri := range docs { 110 + if pf, ok := byPath[duri.Path()]; ok { 111 + walk(pf) 112 + } 113 + } 114 + return active 115 +} 116 + 117 +func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic { 118 + diags := make(map[string][]protocol.Diagnostic) 119 + for _, find := range finds { 120 + file := find.Span.Start.File 121 + if file == "" { 122 + continue 123 + } 124 + diags[file] = append(diags[file], s.findToDiagnostic(find)) 125 + } 126 + return diags 127 +} 128 + 129 +func (s *server) findToDiagnostic(find linter.Find) protocol.Diagnostic { 130 + return protocol.Diagnostic{ 131 + Range: spanToRange(find.Span), 132 + Severity: severityToLSP(find.Severity), 133 + Message: protocol.String(find.Message), 134 + Source: protocol.NewOptional(s.name), 135 + Code: protocol.String(string(find.Code)), 136 + } 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) // TODO: performace 145 + if seen[key] { 146 + continue 147 + } 148 + seen[key] = true 149 + dedup = append(dedup, f) 150 + } 151 + return dedup 152 +} 153 + 154 +func spanToRange(span token.Span) protocol.Range { 155 + return protocol.Range{ 156 + Start: protocol.Position{ 157 + Line: max(0, uint32(span.Start.Line-1)), 158 + Character: max(0, uint32(span.Start.Col-1)), 159 + }, 160 + End: protocol.Position{ 161 + Line: max(0, uint32(span.End.Line-1)), 162 + Character: uint32(max(0, span.End.Col-1)), 163 + }, 164 + } 165 +} 166 + 167 +func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity { 168 + switch s { 169 + case linter.SeverityError: 170 + return protocol.DiagnosticSeverityError 171 + case linter.SeverityWarning: 172 + return protocol.DiagnosticSeverityWarning 173 + case linter.SeverityInfo: 174 + return protocol.DiagnosticSeverityInformation 175 + case linter.SeverityHint: 176 + return protocol.DiagnosticSeverityHint 177 + default: 178 + panic("impossible diagnostic severity") 179 + } 180 +}
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 +) 16 + 17 +type Server struct{ server *server } 18 + 19 +func NewServer(version string) Server { 20 + return Server{ 21 + &server{ 22 + name: "clerk", 23 + version: version, 24 + 25 + openDocs: make(map[uri.URI]docState), 26 + 27 + linter: linter.NewLinter(linter.Rules), 28 + loader: journal.NewLoader(), 29 + 30 + log: 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/semantic" 15 +) 16 + 17 +type server struct { 18 + protocol.UnimplementedServer 19 + 20 + client protocol.Client 21 + log *slog.Logger 22 + 23 + version, name string 24 + 25 + linter *linter.Linter 26 + loader *journal.Loader 27 + debouncer *time.Timer 28 + 29 + mu sync.Mutex 30 + openDocs map[uri.URI]docState 31 + semanticCtx *semantic.Context 32 +} 33 + 34 +type docState struct { 35 + text string 36 + version int32 37 + languageID protocol.LanguageKind 38 +} 39 + 40 +func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { 41 + return &protocol.InitializeResult{ 42 + ServerInfo: protocol.ServerInfo{ 43 + Name: s.name, 44 + Version: protocol.NewOptional(s.version), 45 + }, 46 + Capabilities: protocol.ServerCapabilities{ 47 + DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 48 + TextDocumentSync: &protocol.TextDocumentSyncOptions{ 49 + OpenClose: new(true), 50 + Change: new(protocol.TextDocumentSyncKindFull), 51 + }, 52 + }, 53 + }, nil 54 +} 55 + 56 +func (s *server) Initialized(ctx context.Context, params *protocol.InitializedParams) error { 57 + s.scheduleWorkspaceRebuild(ctx) 58 + return nil 59 +} 60 + 61 +func (s *server) Shutdown(ctx context.Context) error { 62 + s.cancelWorkspaceRebuild() 63 + return nil 64 +} 65 + 66 +func (s *server) Exit(ctx context.Context) error { 67 + return nil 68 +}
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, params.TextDocument.Text, params.TextDocument.Version, params.TextDocument.LanguageID) 12 + s.scheduleWorkspaceRebuild(ctx) 13 + return nil 14 +} 15 + 16 +func (s *server) DidChange(ctx context.Context, params *protocol.DidChangeTextDocumentParams) error { 17 + s.updateDoc(params.TextDocument.URI, params.TextDocument.Version, params.ContentChanges) 18 + s.scheduleWorkspaceRebuild(ctx) 19 + return nil 20 +} 21 + 22 +func (s *server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error { 23 + s.closeDoc(params.TextDocument.URI) 24 + s.scheduleWorkspaceRebuild(ctx) 25 + return nil 26 +} 27 + 28 +func (s *server) DidSave(ctx context.Context, params *protocol.DidSaveTextDocumentParams) error { 29 + return nil 30 +} 31 + 32 +// Document management 33 + 34 +func (s *server) openDoc(u uri.URI, text string, version int32, langID protocol.LanguageKind) { 35 + s.mu.Lock() 36 + s.openDocs[u] = docState{ 37 + text: text, 38 + version: version, 39 + languageID: langID, 40 + } 41 + s.mu.Unlock() 42 +} 43 + 44 +func (s *server) updateDoc(u uri.URI, version int32, changes []protocol.TextDocumentContentChangeEvent) { 45 + s.mu.Lock() 46 + defer s.mu.Unlock() 47 + state, ok := s.openDocs[u] 48 + if !ok { 49 + return 50 + } 51 + state.version = version 52 + for _, ch := range changes { 53 + switch ev := ch.(type) { 54 + case *protocol.TextDocumentContentChangeWholeDocument: 55 + state.text = ev.Text 56 + case *protocol.TextDocumentContentChangePartial: 57 + _ = ev // TODO: incremental edit support 58 + } 59 + } 60 + s.openDocs[u] = state 61 +} 62 + 63 +func (s *server) closeDoc(u uri.URI) { 64 + s.mu.Lock() 65 + delete(s.openDocs, u) 66 + s.mu.Unlock() 67 +} 68 + 69 +func (s *server) getDocText(u uri.URI) (string, bool) { 70 + s.mu.Lock() 71 + state, ok := s.openDocs[u] 72 + s.mu.Unlock() 73 + return state.text, ok 74 +}
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)