package lsp import ( "context" "fmt" "time" "go.lsp.dev/jsonrpc2" "go.lsp.dev/protocol" "go.lsp.dev/uri" "olexsmir.xyz/clerk/internal/linter" "olexsmir.xyz/clerk/journal" "olexsmir.xyz/clerk/journal/semantic" "olexsmir.xyz/clerk/journal/token" ) func (s *server) rebuildWorkspace(ctx context.Context) { s.logger.Debug("rebuilding workspace") s.mu.Lock() defer s.mu.Unlock() if len(s.openDocs) == 0 { return } // reload all open docs from in-memory content for duri, state := range s.openDocs { path := duri.Path() if _, err := s.loader.Reload(path, []byte(state.text)); err != nil { s.logger.Warn("failed to relod open document", "uri", duri, "err", err) } } roots := s.loader.Roots() if len(roots) == 0 { s.logger.Debug("no files in workspace") return } var allFiles []*journal.ParsedFile var finds []linter.Find for _, root := range roots { files := journal.CollectFiles(root) c := semantic.Build(files) finds = append(finds, s.linter.Run(c)...) allFiles = append(allFiles, files...) } finds = dedupFinds(finds) s.semaMu.Lock() s.semanticCtx = semantic.Build(allFiles) s.semaMu.Unlock() diagsByFile := groupFindsByFile(finds) activeFiles := activeFileSet(s.openDocs, s.loader.Ordered()) for fpath := range activeFiles { diags := diagsByFile[fpath] if diags == nil { // TODO: not sure if it's needed diags = []protocol.Diagnostic{} } if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{ URI: uri.File(fpath), Diagnostics: diags, }); err != nil { s.logger.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err) } } s.logger.Debug("workspace rebuild complete", "files", len(s.loader.Ordered()), "findings", len(finds)) } func (s *server) scheduleWorkspaceRebuild(ctx context.Context) { s.mu.Lock() if s.debouncer != nil { s.debouncer.Stop() } ctx = jsonrpc2.DetachContext(ctx) s.debouncer = time.AfterFunc(200*time.Millisecond, func() { s.rebuildWorkspace(ctx) }) s.mu.Unlock() } func (s *server) cancelWorkspaceRebuild() { s.mu.Lock() if s.debouncer != nil { s.debouncer.Stop() s.debouncer = nil } s.mu.Unlock() } func activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool { active := make(map[string]bool) for duri := range docs { active[duri.Path()] = true } visited := make(map[string]bool) var walk func(*journal.ParsedFile) walk = func(pf *journal.ParsedFile) { if visited[pf.Path] { return } active[pf.Path] = true visited[pf.Path] = true for _, inc := range pf.Includes { walk(inc) } } byPath := make(map[string]*journal.ParsedFile, len(ordered)) for _, pf := range ordered { byPath[pf.Path] = pf } for duri := range docs { if pf, ok := byPath[duri.Path()]; ok { walk(pf) } } return active } func groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic { diags := make(map[string][]protocol.Diagnostic) for _, find := range finds { file := find.Span.Start.File if file == "" { continue } diags[file] = append(diags[file], findToDiagnostic(find)) } return diags } func dedupFinds(finds []linter.Find) []linter.Find { seen := make(map[string]bool) dedup := make([]linter.Find, 0, len(finds)) for _, f := range finds { s := f.Span.Start key := fmt.Sprintf("%s:%d:%d:%s", s.File, s.Line, s.Col, f.Code) if seen[key] { continue } seen[key] = true dedup = append(dedup, f) } return dedup } func findToDiagnostic(find linter.Find) protocol.Diagnostic { return protocol.Diagnostic{ Range: spanToRange(find.Span), Severity: severityToLSP(find.Severity), Message: protocol.String(find.Message), Source: protocol.NewOptional(name), Code: protocol.String(string(find.Code)), } } func spanToRange(span token.Span) protocol.Range { return protocol.Range{ Start: protocol.Position{ Line: max(0, uint32(span.Start.Line-1)), Character: max(0, uint32(span.Start.Col-1)), }, End: protocol.Position{ Line: max(0, uint32(span.End.Line-1)), Character: uint32(max(0, span.End.Col-1)), }, } } func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity { switch s { case linter.SeverityError: return protocol.DiagnosticSeverityError case linter.SeverityWarning: return protocol.DiagnosticSeverityWarning case linter.SeverityInfo: return protocol.DiagnosticSeverityInformation case linter.SeverityHint: return protocol.DiagnosticSeverityHint default: panic("impossible diagnostic severity") } }