7 files changed,
257 insertions(+),
53 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-11 12:58:15 +0300
Authored at:
2026-06-28 23:24:08 +0300
Change ID:
xvywzlunvukrnlrxqxprxuxlrxoyplpv
Parent:
8e87a98
A
internal/lsp/diagnostics.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "time" 6 + 7 + "go.lsp.dev/jsonrpc2" 8 + "go.lsp.dev/protocol" 9 + "go.lsp.dev/uri" 10 + "olexsmir.xyz/clerk/internal/linter" 11 + "olexsmir.xyz/clerk/journal" 12 + "olexsmir.xyz/clerk/journal/semantic" 13 + "olexsmir.xyz/clerk/journal/token" 14 +) 15 + 16 +func (s *server) rebuildWorkspace(ctx context.Context) { 17 + s.logger.Debug("rebuilding workspace") 18 + 19 + s.mu.Lock() 20 + defer s.mu.Unlock() 21 + 22 + if len(s.openDocs) == 0 { 23 + return 24 + } 25 + 26 + // reload all open docs from in-memory content 27 + for duri, state := range s.openDocs { 28 + path := duri.Path() 29 + if _, err := s.loader.Reload(path, []byte(state.text)); err != nil { 30 + s.logger.Warn("failed to relod open document", "uri", duri, "err", err) 31 + } 32 + } 33 + 34 + ordered := s.loader.Ordered() 35 + if len(ordered) == 0 { 36 + s.logger.Debug("no files in workspace") 37 + return 38 + } 39 + 40 + s.sema = semantic.Build(ordered) // TODO: add dynamic relaoder to sema 41 + finds := s.linter.Run(s.sema) 42 + 43 + diagsByFile := groupFindsByFile(finds) 44 + activeFiles := activeFileSet(s.openDocs, ordered) 45 + for fpath := range activeFiles { 46 + diags := diagsByFile[fpath] 47 + if diags == nil { // TODO: not sure if it's needed 48 + diags = []protocol.Diagnostic{} 49 + } 50 + if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ 51 + URI: uri.File(fpath), 52 + Diagnostics: diags, 53 + }); err != nil { 54 + s.logger.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err) 55 + } 56 + } 57 + 58 + s.logger.Debug("workspace rebuild complete", "files", len(ordered), "findings", len(finds)) 59 +} 60 + 61 +func (s *server) scheduleWorkspaceRebuild(ctx context.Context) { 62 + s.mu.Lock() 63 + if s.debouncer != nil { 64 + s.debouncer.Stop() 65 + } 66 + ctx = jsonrpc2.DetachContext(ctx) 67 + s.debouncer = time.AfterFunc(200*time.Millisecond, func() { 68 + s.rebuildWorkspace(ctx) 69 + }) 70 + s.mu.Unlock() 71 +} 72 + 73 +func (s *server) cancelWorkspaceRebuild() { 74 + s.mu.Lock() 75 + if s.debouncer != nil { 76 + s.debouncer.Stop() 77 + s.debouncer = nil 78 + } 79 + s.mu.Unlock() 80 +} 81 + 82 +func activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool { 83 + active := make(map[string]bool) 84 + for duri := range docs { 85 + active[duri.Path()] = true 86 + } 87 + 88 + visited := make(map[string]bool) 89 + var walk func(*journal.ParsedFile) 90 + walk = func(pf *journal.ParsedFile) { 91 + if visited[pf.Path] { 92 + return 93 + } 94 + active[pf.Path] = true 95 + visited[pf.Path] = true 96 + for _, inc := range pf.Includes { 97 + walk(inc) 98 + } 99 + } 100 + 101 + byPath := make(map[string]*journal.ParsedFile, len(ordered)) 102 + for _, pf := range ordered { 103 + byPath[pf.Path] = pf 104 + } 105 + for duri := range docs { 106 + if pf, ok := byPath[duri.Path()]; ok { 107 + walk(pf) 108 + } 109 + } 110 + 111 + return active 112 +} 113 + 114 +func groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic { 115 + diags := make(map[string][]protocol.Diagnostic) 116 + for _, find := range finds { 117 + file := find.Span.Start.File 118 + if file == "" { 119 + continue 120 + } 121 + diags[file] = append(diags[file], findToDiagnostic(find)) 122 + } 123 + return diags 124 +} 125 + 126 +func findToDiagnostic(find linter.Find) protocol.Diagnostic { 127 + return protocol.Diagnostic{ 128 + Range: spanToRange(find.Span), 129 + Severity: severityToLSP(find.Severity), 130 + Message: protocol.String(find.Message), 131 + Source: protocol.NewOptional(name), 132 + Code: protocol.String(string(find.Code)), 133 + } 134 +} 135 + 136 +func spanToRange(span token.Span) protocol.Range { 137 + return protocol.Range{ 138 + Start: protocol.Position{ 139 + Line: max(0, uint32(span.Start.Line-1)), 140 + Character: max(0, uint32(span.Start.Col-1)), 141 + }, 142 + End: protocol.Position{ 143 + Line: max(0, uint32(span.End.Line-1)), 144 + Character: uint32(max(0, span.End.Col-1)), 145 + }, 146 + } 147 +} 148 + 149 +func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity { 150 + switch s { 151 + case linter.SeverityError: 152 + return protocol.DiagnosticSeverityError 153 + case linter.SeverityWarning: 154 + return protocol.DiagnosticSeverityWarning 155 + case linter.SeverityInfo: 156 + return protocol.DiagnosticSeverityInformation 157 + case linter.SeverityHint: 158 + return protocol.DiagnosticSeverityHint 159 + default: 160 + panic("impossible diagnostic severity") 161 + } 162 +}
M
internal/lsp/lsp.go
··· 3 3 import ( 4 4 "context" 5 5 "io" 6 + "log/slog" 7 + "os" 6 8 7 9 "go.lsp.dev/jsonrpc2" 8 10 "go.lsp.dev/protocol" 9 11 "go.lsp.dev/uri" 10 12 11 13 "olexsmir.xyz/clerk/internal/linter" 14 + "olexsmir.xyz/clerk/journal" 12 15 "olexsmir.xyz/clerk/journal/printer" 13 - "olexsmir.xyz/clerk/journal/semantic" 14 16 ) 15 17 16 -type Server struct { 17 - sema *semantic.Context 18 - linter *linter.Linter 19 - 20 - version string 21 - server *server 22 -} 18 +type Server struct{ server *server } 23 19 24 20 func NewServer(version string) *Server { 25 21 return &Server{ 26 - // sema: sema, 27 - // linter: linter, 28 22 server: &server{ 29 - version: version, 23 + version: version, 24 + openDocs: make(map[uri.URI]docState), 25 + 26 + loader: journal.NewLoader(), 27 + linter: linter.NewLinter(linter.Rules), 30 28 printerCfg: printer.DefaultConfig, 31 - docs: make(map[uri.URI]docState), 29 + 30 + logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{})), 32 31 }, 33 32 } 34 33 }
M
internal/lsp/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "log/slog" 5 6 "sync" 7 + "time" 6 8 7 9 "go.lsp.dev/protocol" 8 10 "go.lsp.dev/uri" 9 11 12 + "olexsmir.xyz/clerk/internal/linter" 13 + "olexsmir.xyz/clerk/journal" 10 14 "olexsmir.xyz/clerk/journal/printer" 15 + "olexsmir.xyz/clerk/journal/semantic" 11 16 ) 12 17 18 +const name = "clerk" 19 + 13 20 type docState struct { 14 21 text string 15 22 version int32 ··· 19 26 type server struct { 20 27 protocol.UnimplementedServer 21 28 22 - client protocol.Client 29 + client protocol.Client 30 + 23 31 version string 24 32 25 33 printerCfg *printer.Config 34 + logger *slog.Logger 26 35 27 - mu sync.RWMutex 28 - docs map[uri.URI]docState 36 + loader *journal.Loader 37 + sema *semantic.Context 38 + linter *linter.Linter 39 + debouncer *time.Timer 40 + 41 + mu sync.Mutex 42 + openDocs map[uri.URI]docState 29 43 } 30 44 31 45 func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { 32 46 return &protocol.InitializeResult{ 33 47 ServerInfo: protocol.ServerInfo{ 34 - Name: "clerk", 48 + Name: name, 35 49 Version: protocol.NewOptional(s.version), 36 50 }, 37 51 Capabilities: protocol.ServerCapabilities{ 38 - DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 39 52 TextDocumentSync: &protocol.TextDocumentSyncOptions{ 40 53 OpenClose: new(true), 41 - Change: new(protocol.TextDocumentSyncKindFull), // TODO: Incremental 54 + Change: new(protocol.TextDocumentSyncKindFull), 42 55 }, 43 - // TODO: diagnostics provider 44 - // TODO: completion 56 + DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 57 + // CompletionProvider: &protocol.CompletionOptions{ 58 + // TriggerCharacters: []string{" ", ":", "/"}, 59 + // }, 45 60 }, 46 61 }, nil 47 62 } 48 63 49 64 func (s *server) Initialized(ctx context.Context, params *protocol.InitializedParams) error { 65 + s.scheduleWorkspaceRebuild(ctx) 50 66 return nil 51 67 } 52 68 53 69 func (s *server) Shutdown(ctx context.Context) error { 70 + s.cancelWorkspaceRebuild() 54 71 return nil 55 72 } 56 73
M
internal/lsp/textdocument_format.go
··· 6 6 "strings" 7 7 8 8 "go.lsp.dev/protocol" 9 + 9 10 "olexsmir.xyz/clerk/journal/lexer" 10 11 "olexsmir.xyz/clerk/journal/parser" 11 12 ) 12 13 13 14 func (s *server) Formatting(ctx context.Context, params *protocol.DocumentFormattingParams) ([]protocol.TextEdit, error) { 14 - s.mu.RLock() 15 - state, ok := s.docs[params.TextDocument.URI] 16 - s.mu.RUnlock() 15 + text, ok := s.getDocText(params.TextDocument.URI) 17 16 if !ok { 18 17 return nil, nil 19 18 } 20 19 21 - lex := lexer.New(params.TextDocument.URI.Path(), []byte(state.text)) 20 + lex := lexer.New(params.TextDocument.URI.Path(), []byte(text)) 22 21 jnrl := parser.New(lex).ParseJournal() 23 22 if len(jnrl.Errors) > 0 { 24 23 return nil, fmt.Errorf("can't format file with errors: %v", jnrl.Errors[0].Message) ··· 30 29 } 31 30 newText := buf.String() 32 31 33 - if newText == state.text { 32 + if newText == text { 34 33 return nil, nil 35 34 } 36 35 37 - endLine, endChar := getEndLineAndChar(state.text) 36 + endLine, endChar := getEndLineAndChar(text) 38 37 return []protocol.TextEdit{{ 39 38 NewText: newText, 40 39 Range: protocol.Range{
M
internal/lsp/textdocument_sync.go
··· 8 8 ) 9 9 10 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) { 11 38 s.mu.Lock() 12 - s.docs[params.TextDocument.URI] = docState{ 13 - text: params.TextDocument.Text, 14 - version: params.TextDocument.Version, 15 - languageID: params.TextDocument.LanguageID, 39 + s.openDocs[u] = docState{ 40 + text: text, 41 + version: version, 42 + languageID: langID, 16 43 } 17 44 s.mu.Unlock() 18 - return nil 19 45 } 20 46 21 -func (s *server) DidChange(ctx context.Context, params *protocol.DidChangeTextDocumentParams) error { 47 +func (s *server) updateDoc(u uri.URI, version int32, changes []protocol.TextDocumentContentChangeEvent) { 22 48 s.mu.Lock() 23 49 defer s.mu.Unlock() 24 50 25 - state, ok := s.docs[params.TextDocument.URI] 51 + state, ok := s.openDocs[u] 26 52 if !ok { 27 - return nil 53 + return 28 54 } 29 - 30 - state.version = params.TextDocument.Version 31 - for _, ch := range params.ContentChanges { 55 + state.version = version 56 + for _, ch := range changes { 32 57 switch ev := ch.(type) { 33 58 case *protocol.TextDocumentContentChangeWholeDocument: 34 59 state.text = ev.Text 35 60 case *protocol.TextDocumentContentChangePartial: 36 - // TODO: incremental edit — apply range replacement 37 - _ = ev 61 + _ = ev // TODO: incremental edit support 38 62 } 39 63 } 40 - s.docs[params.TextDocument.URI] = state 41 - return nil 64 + s.openDocs[u] = state 42 65 } 43 66 44 -func (s *server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error { 67 +func (s *server) closeDoc(u uri.URI) { 45 68 s.mu.Lock() 46 - defer s.mu.Unlock() 47 - delete(s.docs, params.TextDocument.URI) 48 - return nil 49 -} 50 - 51 -func (s *server) DidSave(ctx context.Context, params *protocol.DidSaveTextDocumentParams) error { 52 - return nil 69 + delete(s.openDocs, u) 70 + s.mu.Unlock() 53 71 } 54 72 55 -func (s *server) GetText(uri uri.URI) (string, bool) { 56 - s.mu.RLock() 57 - defer s.mu.RUnlock() 58 - state, ok := s.docs[uri] 73 +func (s *server) getDocText(u uri.URI) (string, bool) { 74 + s.mu.Lock() 75 + state, ok := s.openDocs[u] 76 + s.mu.Unlock() 59 77 return state.text, ok 60 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 + 40 49 // Ordered returns all files in dependency order (included before includer) 41 50 func (l *Loader) Ordered() []*ParsedFile { 42 51 visited := make(map[string]bool)