all repos

clerk @ 225a2d3

missing tooling for ledger/hledger
7 files changed, 257 insertions(+), 53 deletions(-)
lsp: diagnostics
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)