all repos

clerk @ 1b9e6e1

missing tooling for ledger/hledger
15 files changed, 429 insertions(+), 85 deletions(-)
lsp: comply with lsp3.18 lifecycle
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-09-10 13:33:34 +0300
Authored at: 2026-09-08 15:18:37 +0300
Change ID: rnrotwollprovssurotuxlxnoqqmokuz
Parent: 8909472
M internal/cli/cmd_lsp.go
···
        2
        2
         

      
        3
        3
         import (

      
        4
        4
         	"context"

      
        
        5
        +	"errors"

      
        5
        6
         	"os"

      
        6
        7
         

      
        7
        8
         	"github.com/urfave/cli/v3"

      ···
        14
        15
         	if err != nil {

      
        15
        16
         		return err

      
        16
        17
         	}

      
        17
        
        -

      
        18
        18
         	server, err := lsp.NewServer(c.version, configPath)

      
        19
        19
         	if err != nil {

      
        20
        20
         		return err

      
        21
        21
         	}

      
        22
        
        -	return server.Run(ctx, os.Stdin, os.Stdout)

      
        
        22
        +	if err := server.Run(ctx, os.Stdin, os.Stdout); err != nil {

      
        
        23
        +		if lee, ok := errors.AsType[*lsp.ExitError](err); ok {

      
        
        24
        +			os.Exit(lee.Code)

      
        
        25
        +		}

      
        
        26
        +		return err

      
        
        27
        +	}

      
        
        28
        +	return nil

      
        23
        29
         }

      
M internal/lsp/diagnostics.go
···
        9
        9
         	"go.lsp.dev/uri"

      
        10
        10
         

      
        11
        11
         	"olexsmir.xyz/clerk/internal/linter"

      
        12
        
        -	"olexsmir.xyz/clerk/journal/token"

      
        
        12
        +	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        13
        13
         )

      
        14
        14
         

      
        15
        15
         const diagDebounce = 200 * time.Millisecond

      ···
        61
        61
         	}

      
        62
        62
         

      
        63
        63
         	var finds []linter.Find

      
        
        64
        +	lines := make(map[string]*lsputil.LineIndex)

      
        64
        65
         	paths := make(map[string]bool)

      
        65
        66
         	for _, u := range dirtyURIs {

      
        66
        67
         		a := s.analysisFor(u)

      
        67
        68
         		if a == nil {

      
        68
        69
         			continue

      
        
        70
        +		}

      
        
        71
        +		var idx map[string]*lsputil.LineIndex

      
        
        72
        +		if st, ok := s.getDocState(u); ok && st.cache != nil {

      
        
        73
        +			idx = st.cache.fileIdx

      
        
        74
        +		} else {

      
        
        75
        +			idx = buildFileIdx(a)

      
        69
        76
         		}

      
        70
        77
         		for _, pf := range a.Files {

      
        
        78
        +			lines[pf.Path] = idx[pf.Path]

      
        71
        79
         			paths[pf.Path] = true

      
        72
        80
         		}

      
        73
        81
         		finds = append(finds, lint.Run(a)...)

      ···
        78
        86
         	}

      
        79
        87
         

      
        80
        88
         	s.assignSeverities(finds)

      
        81
        
        -	diagsByFile := s.groupFindsByFile(dedupFinds(finds))

      
        
        89
        +	diagsByFile := s.groupFindsByFile(dedupFinds(finds), lines)

      
        82
        90
         	for fpath := range paths {

      
        83
        91
         		if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{

      
        84
        92
         			URI:         uri.File(fpath),

      ···
        91
        99
         	s.log.Debug("diagnostics published", "files", len(paths), "findings", len(finds))

      
        92
        100
         }

      
        93
        101
         

      
        94
        
        -func (s *server) groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic {

      
        
        102
        +func (s *server) groupFindsByFile(finds []linter.Find, lines map[string]*lsputil.LineIndex) map[string][]protocol.Diagnostic {

      
        95
        103
         	// count per file to pre-size the diagnostic slices: append growth on ~10k

      
        96
        104
         	// findings is the dominant allocation in the diagnostics path

      
        97
        105
         	counts := make(map[string]int, len(finds))

      ···
        109
        117
         		if file == "" {

      
        110
        118
         			continue

      
        111
        119
         		}

      
        112
        
        -		diags[file] = append(diags[file], s.findToDiagnostic(find))

      
        
        120
        +		diags[file] = append(diags[file], s.findToDiagnostic(find, lines[file]))

      
        113
        121
         	}

      
        114
        122
         	return diags

      
        115
        123
         }

      
        116
        124
         

      
        117
        
        -func (s *server) findToDiagnostic(find linter.Find) protocol.Diagnostic {

      
        
        125
        +func (s *server) findToDiagnostic(find linter.Find, lines *lsputil.LineIndex) protocol.Diagnostic {

      
        118
        126
         	return protocol.Diagnostic{

      
        119
        
        -		Range:    spanToRange(find.Span),

      
        
        127
        +		Range:    lines.SpanRange(find.Span),

      
        120
        128
         		Severity: severityToLSP(find.Severity),

      
        121
        129
         		Message:  protocol.String(find.Message),

      
        122
        130
         		Source:   protocol.NewOptional(s.name),

      ···
        142
        150
         	file      string

      
        143
        151
         	line, col int

      
        144
        152
         	code      linter.RuleID

      
        145
        
        -}

      
        146
        
        -

      
        147
        
        -func spanToRange(span token.Span) protocol.Range {

      
        148
        
        -	return protocol.Range{

      
        149
        
        -		Start: protocol.Position{

      
        150
        
        -			Line:      max(0, uint32(span.Start.Line-1)),

      
        151
        
        -			Character: max(0, uint32(span.Start.Col-1)),

      
        152
        
        -		},

      
        153
        
        -		End: protocol.Position{

      
        154
        
        -			Line:      max(0, uint32(span.End.Line-1)),

      
        155
        
        -			Character: uint32(max(0, span.End.Col-1)),

      
        156
        
        -		},

      
        157
        
        -	}

      
        158
        153
         }

      
        159
        154
         

      
        160
        155
         func (s *server) assignSeverities(finds []linter.Find) {

      
M internal/lsp/diagnostics_test.go
···
        17
        17
         		b.Fatal(err)

      
        18
        18
         	}

      
        19
        19
         

      
        20
        
        -	// Per-edit cost: a fresh loader skips the parse cache, so each iteration re-parses, then lints and groups findings.

      
        
        20
        +	// Per-edit cost: a fresh loader skips the parse cache, so each iteration re-parses,

      
        
        21
        +	// builds the per-file indices with the analysis, then lints and groups findings.

      
        21
        22
         	b.ReportAllocs()

      
        22
        23
         	b.ResetTimer()

      
        23
        24
         	for b.Loop() {

      
        24
        25
         		an := analyzer.Build(journal.NewLoader().ResolveBytes("/test.journal", []byte(content)))

      
        
        26
        +		lines := buildFileIdx(an)

      
        
        27
        +

      
        25
        28
         		finds := dedupFinds(lint.Run(an))

      
        26
        29
         		srv.server.assignSeverities(finds)

      
        27
        
        -		_ = srv.server.groupFindsByFile(finds)

      
        
        30
        +		_ = srv.server.groupFindsByFile(finds, lines)

      
        28
        31
         	}

      
        29
        32
         }

      
A internal/lsp/lifecycle.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"errors"

      
        
        6
        +	"fmt"

      
        
        7
        +	"runtime"

      
        
        8
        +	"syscall"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"go.lsp.dev/jsonrpc2"

      
        
        12
        +	"go.lsp.dev/protocol"

      
        
        13
        +)

      
        
        14
        +

      
        
        15
        +var (

      
        
        16
        +	errServerNotInitialized = jsonrpc2.NewError(jsonrpc2.ServerNotInitialized, "server not initialized")

      
        
        17
        +	errInitializeOnce       = jsonrpc2.NewError(jsonrpc2.InvalidRequest, "initialize may only be sent once")

      
        
        18
        +)

      
        
        19
        +

      
        
        20
        +type ExitError struct{ Code int }

      
        
        21
        +

      
        
        22
        +func (e *ExitError) Error() string {

      
        
        23
        +	return fmt.Sprintf("lsp exit code %d", e.Code)

      
        
        24
        +}

      
        
        25
        +

      
        
        26
        +type serverState int

      
        
        27
        +

      
        
        28
        +const (

      
        
        29
        +	stateNew                 serverState = iota // before the initialize request has been answered

      
        
        30
        +	stateInitialized                            // an InitializeResult was sent

      
        
        31
        +	stateShutdownRequested                      // shutdown was received

      
        
        32
        +	stateExited                                 // exit arrived without a prior shutdown

      
        
        33
        +	stateExitedAfterShutdown                    // exit arrived after shutdown

      
        
        34
        +)

      
        
        35
        +

      
        
        36
        +func (st serverState) ExitCode() int {

      
        
        37
        +	switch st {

      
        
        38
        +	case stateExitedAfterShutdown:

      
        
        39
        +		return 0

      
        
        40
        +	default:

      
        
        41
        +		return 1

      
        
        42
        +	}

      
        
        43
        +}

      
        
        44
        +

      
        
        45
        +// lifecycle gates the connection on the initialize request: before it, only

      
        
        46
        +// initialize and exit are served; everything else answers ServerNotInitialized

      
        
        47
        +// and notifications are dropped. Every later message is served — like gopls,

      
        
        48
        +// the server trusts its client, so shutdown and exit leave subsequent requests

      
        
        49
        +// to fail naturally. The handlers themselves enforce initialize-once and the

      
        
        50
        +// exit code.

      
        
        51
        +func (s *server) lifecycle(next jsonrpc2.Handler) jsonrpc2.Handler {

      
        
        52
        +	return func(ctx context.Context, req *jsonrpc2.Request) (any, error) {

      
        
        53
        +		s.stateMu.Lock()

      
        
        54
        +		st := s.state

      
        
        55
        +		s.stateMu.Unlock()

      
        
        56
        +		if st != stateNew || req.Method() == protocol.MethodInitialize || req.Method() == protocol.MethodExit {

      
        
        57
        +			return next(ctx, req)

      
        
        58
        +		}

      
        
        59
        +		return rejectCall(errServerNotInitialized, req)

      
        
        60
        +	}

      
        
        61
        +}

      
        
        62
        +

      
        
        63
        +// rejectCall answers a call with err and drops a notification.

      
        
        64
        +func rejectCall(err error, req *jsonrpc2.Request) (any, error) {

      
        
        65
        +	if req.IsCall() {

      
        
        66
        +		return nil, err

      
        
        67
        +	}

      
        
        68
        +	return nil, nil // notification dropped

      
        
        69
        +}

      
        
        70
        +

      
        
        71
        +// parentPollInterval is how often watchParent probes the parent process.

      
        
        72
        +const parentPollInterval = 2 * time.Second

      
        
        73
        +

      
        
        74
        +// watchParent exits the server when the parent process named in the initialize

      
        
        75
        +// params dies, as the spec's processId semantics require. Liveness is probed

      
        
        76
        +// with signal 0, which is unix-only; elsewhere the stream EOF that follows a

      
        
        77
        +// parent death covers it. The first probe runs immediately, so a parent that

      
        
        78
        +// is already dead is detected at once. Exit runs the same lifecycle path as

      
        
        79
        +// the exit notification, so the exit code follows the shutdown phase.

      
        
        80
        +func (s *server) watchParent(pid int32) {

      
        
        81
        +	if pid <= 0 || runtime.GOOS == "windows" || runtime.GOOS == "plan9" {

      
        
        82
        +		return

      
        
        83
        +	}

      
        
        84
        +	go func() {

      
        
        85
        +		t := time.NewTicker(parentPollInterval)

      
        
        86
        +		defer t.Stop()

      
        
        87
        +		for {

      
        
        88
        +			// Only ESRCH proves death; nil (alive) and other errors leave it open.

      
        
        89
        +			if err := syscall.Kill(int(pid), 0); err == nil || !errors.Is(err, syscall.ESRCH) {

      
        
        90
        +				<-t.C

      
        
        91
        +				continue

      
        
        92
        +			}

      
        
        93
        +			s.Exit(context.Background())

      
        
        94
        +			return

      
        
        95
        +		}

      
        
        96
        +	}()

      
        
        97
        +}

      
A internal/lsp/lifecycle_test.go
···
        
        1
        +package lsp

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"errors"

      
        
        5
        +	"io"

      
        
        6
        +	"os/exec"

      
        
        7
        +	"testing"

      
        
        8
        +	"time"

      
        
        9
        +

      
        
        10
        +	"go.lsp.dev/jsonrpc2"

      
        
        11
        +	"go.lsp.dev/protocol"

      
        
        12
        +	"go.lsp.dev/uri"

      
        
        13
        +)

      
        
        14
        +

      
        
        15
        +func TestLifecycle_BeforeInitialize(t *testing.T) {

      
        
        16
        +	lct := startLifecycleTest(t)

      
        
        17
        +	// requests answer ServerNotInitialized

      
        
        18
        +	if _, err := lct.srv.Hover(t.Context(), &protocol.HoverParams{}); !codeEq(err, jsonrpc2.ServerNotInitialized) {

      
        
        19
        +		t.Fatalf("request before initialize: want -32002, got %v", err)

      
        
        20
        +	}

      
        
        21
        +	// notifications are dropped without error

      
        
        22
        +	if err := lct.srv.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{

      
        
        23
        +		TextDocument: protocol.TextDocumentItem{URI: uri.File("a.journal"), LanguageID: "journal", Version: 1, Text: "2024-01-01 t\n"},

      
        
        24
        +	}); err != nil {

      
        
        25
        +		t.Fatalf("didOpen before initialize: %v", err)

      
        
        26
        +	}

      
        
        27
        +	// connection is still usable

      
        
        28
        +	if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil {

      
        
        29
        +		t.Fatalf("initialize after dropped notification: %v", err)

      
        
        30
        +	}

      
        
        31
        +}

      
        
        32
        +

      
        
        33
        +func TestLifecycle_InitializeMayOnlyBeSentOnce(t *testing.T) {

      
        
        34
        +	lct := startLifecycleTest(t)

      
        
        35
        +	if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil {

      
        
        36
        +		t.Fatalf("first initialize: %v", err)

      
        
        37
        +	}

      
        
        38
        +	if _, err := lct.srv.Initialize(t.Context(), initParams); !codeEq(err, jsonrpc2.InvalidRequest) {

      
        
        39
        +		t.Fatalf("second initialize: want -32600, got %v", err)

      
        
        40
        +	}

      
        
        41
        +}

      
        
        42
        +

      
        
        43
        +func TestLifecycle_RequestsServedAfterInitialize(t *testing.T) {

      
        
        44
        +	lct := startLifecycleTest(t)

      
        
        45
        +	if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil {

      
        
        46
        +		t.Fatal(err)

      
        
        47
        +	}

      
        
        48
        +	// a request in the running phase must reach the real handler: an unopened

      
        
        49
        +	// document yields an empty semantic-tokens result, never a lifecycle error.

      
        
        50
        +	if _, err := lct.srv.SemanticTokensFull(t.Context(), &protocol.SemanticTokensParams{

      
        
        51
        +		TextDocument: protocol.TextDocumentIdentifier{URI: uri.File("nowhere.journal")},

      
        
        52
        +	}); err != nil {

      
        
        53
        +		t.Fatalf("request after initialize: %v", err)

      
        
        54
        +	}

      
        
        55
        +}

      
        
        56
        +

      
        
        57
        +func TestLifecycle_ExitCodes(t *testing.T) {

      
        
        58
        +	for tname, tc := range map[string]struct {

      
        
        59
        +		wantCode       int

      
        
        60
        +		shutdown, init bool

      
        
        61
        +	}{

      
        
        62
        +		"exit before initialize": {wantCode: 1},

      
        
        63
        +		"exit after initialize":  {wantCode: 1, init: true},

      
        
        64
        +		"exit after shutdown":    {wantCode: 0, init: true, shutdown: true},

      
        
        65
        +	} {

      
        
        66
        +		t.Run(tname, func(t *testing.T) {

      
        
        67
        +			lct := startLifecycleTest(t)

      
        
        68
        +			if tc.init {

      
        
        69
        +				if _, err := lct.srv.Initialize(t.Context(), initParams); err != nil {

      
        
        70
        +					t.Fatal(err)

      
        
        71
        +				}

      
        
        72
        +			}

      
        
        73
        +			if tc.shutdown {

      
        
        74
        +				if err := lct.srv.Shutdown(t.Context()); err != nil {

      
        
        75
        +					t.Fatal(err)

      
        
        76
        +				}

      
        
        77
        +				// like gopls, the server trusts its client: a request after

      
        
        78
        +				// shutdown is still served (a nonexistent document yields an

      
        
        79
        +				// empty hover), never a lifecycle error.

      
        
        80
        +				if _, err := lct.srv.Hover(t.Context(), &protocol.HoverParams{}); err != nil {

      
        
        81
        +					t.Fatalf("request after shutdown: %v", err)

      
        
        82
        +				}

      
        
        83
        +			}

      
        
        84
        +			if err := lct.srv.Exit(t.Context()); err != nil {

      
        
        85
        +				t.Fatal(err)

      
        
        86
        +			}

      
        
        87
        +			var ee *ExitError

      
        
        88
        +			if err := <-lct.errc; !errors.As(err, &ee) || ee.Code != tc.wantCode {

      
        
        89
        +				t.Fatalf("want code %d, got %v", tc.wantCode, err)

      
        
        90
        +			}

      
        
        91
        +		})

      
        
        92
        +	}

      
        
        93
        +}

      
        
        94
        +

      
        
        95
        +func TestLifecycle_ParentDeathExits(t *testing.T) {

      
        
        96
        +	if testing.Short() {

      
        
        97
        +		t.Skip("spawns a subprocess")

      
        
        98
        +	}

      
        
        99
        +

      
        
        100
        +	child := exec.Command("sleep", "30")

      
        
        101
        +	if err := child.Start(); err != nil {

      
        
        102
        +		t.Fatal(err)

      
        
        103
        +	}

      
        
        104
        +	pid := int32(child.Process.Pid)

      
        
        105
        +	child.Process.Kill()

      
        
        106
        +	child.Wait() // reap so the immediate signal-0 probe sees ESRCH

      
        
        107
        +

      
        
        108
        +	lct := startLifecycleTest(t)

      
        
        109
        +

      
        
        110
        +	// initialize in the background: watchParent's immediate probe fires while

      
        
        111
        +	// the request is still being handled, so its response is dropped and the

      
        
        112
        +	// server exits before the call returns.

      
        
        113
        +	go func() { lct.srv.Initialize(t.Context(), &protocol.InitializeParams{ProcessID: &pid}) }()

      
        
        114
        +

      
        
        115
        +	select {

      
        
        116
        +	case err := <-lct.errc:

      
        
        117
        +		var ee *ExitError

      
        
        118
        +		if !errors.As(err, &ee) || ee.Code != 1 {

      
        
        119
        +			t.Fatalf("parent death: want code 1, got %v", err)

      
        
        120
        +		}

      
        
        121
        +	case <-time.After(5 * time.Second):

      
        
        122
        +		t.Fatal("server did not exit after parent death")

      
        
        123
        +	}

      
        
        124
        +}

      
        
        125
        +

      
        
        126
        +var initParams = &protocol.InitializeParams{

      
        
        127
        +	WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{},

      
        
        128
        +	Capabilities:                     protocol.ClientCapabilities{},

      
        
        129
        +}

      
        
        130
        +

      
        
        131
        +func codeEq(err error, code jsonrpc2.Code) bool {

      
        
        132
        +	return errors.Is(err, jsonrpc2.NewError(code, ""))

      
        
        133
        +}

      
        
        134
        +

      
        
        135
        +type lifecycleTest struct {

      
        
        136
        +	errc chan error

      
        
        137
        +	srv  protocol.Server

      
        
        138
        +}

      
        
        139
        +

      
        
        140
        +func startLifecycleTest(t *testing.T) *lifecycleTest {

      
        
        141
        +	t.Helper()

      
        
        142
        +	s := newServer(t)

      
        
        143
        +	lct := &lifecycleTest{errc: make(chan error, 1)}

      
        
        144
        +	inR, inW := io.Pipe()   // client -> server

      
        
        145
        +	outR, outW := io.Pipe() // server -> client

      
        
        146
        +

      
        
        147
        +	go func() { lct.errc <- s.Run(t.Context(), inR, outW) }()

      
        
        148
        +	t.Cleanup(func() {

      
        
        149
        +		inW.Close()

      
        
        150
        +		outR.Close()

      
        
        151
        +	})

      
        
        152
        +

      
        
        153
        +	cconn := jsonrpc2.NewConn(jsonrpc2.NewStream(readWriterCloser{Reader: outR, Writer: inW, Closer: outR}), jsonrpc2.WithCodec(lspCodec{}))

      
        
        154
        +	lct.srv = protocol.ServerDispatcher(cconn)

      
        
        155
        +	cconn.Go(t.Context(), protocol.Handlers(protocol.ClientHandler(&captureClient{}, jsonrpc2.MethodNotFoundHandler)))

      
        
        156
        +	return lct

      
        
        157
        +}

      
M internal/lsp/lsp.go
···
        46
        46
         		Writer: stdout,

      
        47
        47
         		Closer: stdin,

      
        48
        48
         	})

      
        49
        
        -

      
        50
        
        -	_, conn, client := protocol.NewServer(ctx, s.server, stream)

      
        51
        
        -	defer conn.Close()

      
        52
        
        -

      
        53
        
        -	s.server.client = client

      
        
        49
        +	conn := jsonrpc2.NewConn(stream, jsonrpc2.WithCodec(lspCodec{}))

      
        
        50
        +	s.server.client = protocol.ClientDispatcher(conn)

      
        
        51
        +	s.server.conn = conn

      
        
        52
        +	conn.Go(ctx, protocol.Handlers(s.server.lifecycle(protocol.ServerHandler(s.server, jsonrpc2.MethodNotFoundHandler))))

      
        54
        53
         

      
        55
        54
         	<-conn.Done()

      
        
        55
        +	s.server.stateMu.Lock()

      
        
        56
        +	st := s.server.state

      
        
        57
        +	s.server.stateMu.Unlock()

      
        
        58
        +	if st >= stateExited {

      
        
        59
        +		return &ExitError{Code: st.ExitCode()}

      
        
        60
        +	}

      
        56
        61
         	return conn.Err()

      
        57
        62
         }

      
        58
        63
         

      ···
        82
        87
         	}

      
        83
        88
         	return os.OpenFile(filepath.Join(dir, "lsp.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)

      
        84
        89
         }

      
        
        90
        +

      
        
        91
        +// lspCodec mirros go.lsp.dev/protocol wire codec, so Run can install the lifecycle guard.

      
        
        92
        +type lspCodec struct{}

      
        
        93
        +

      
        
        94
        +func (lspCodec) Marshal(v any) ([]byte, error) {

      
        
        95
        +	switch m := v.(type) {

      
        
        96
        +	case jsonrpc2.RawMessage:

      
        
        97
        +		if m == nil {

      
        
        98
        +			return []byte("null"), nil

      
        
        99
        +		}

      
        
        100
        +		return m, nil

      
        
        101
        +	case *jsonrpc2.RawMessage:

      
        
        102
        +		if m == nil || *m == nil {

      
        
        103
        +			return []byte("null"), nil

      
        
        104
        +		}

      
        
        105
        +		return *m, nil

      
        
        106
        +	}

      
        
        107
        +	return protocol.Marshal(v)

      
        
        108
        +}

      
        
        109
        +

      
        
        110
        +func (lspCodec) Unmarshal(data []byte, v any) error {

      
        
        111
        +	if p, ok := v.(*jsonrpc2.RawMessage); ok {

      
        
        112
        +		b := make(jsonrpc2.RawMessage, len(data))

      
        
        113
        +		copy(b, data)

      
        
        114
        +		*p = b

      
        
        115
        +		return nil

      
        
        116
        +	}

      
        
        117
        +	return protocol.Unmarshal(data, v)

      
        
        118
        +}

      
M internal/lsp/server.go
···
        10
        10
         

      
        11
        11
         	"github.com/go-json-experiment/json"

      
        12
        12
         	"github.com/pelletier/go-toml/v2"

      
        
        13
        +	"go.lsp.dev/jsonrpc2"

      
        13
        14
         	"go.lsp.dev/protocol"

      
        14
        15
         	"go.lsp.dev/uri"

      
        15
        16
         

      ···
        23
        24
         

      
        24
        25
         	client protocol.Client

      
        25
        26
         	log    *slog.Logger

      
        
        27
        +	conn   jsonrpc2.Conn // set in Run; Exit closes it to end the session

      
        26
        28
         

      
        
        29
        +	stateMu       sync.Mutex

      
        
        30
        +	state         serverState // [serverState]

      
        27
        31
         	version, name string

      
        28
        32
         

      
        29
        33
         	settings settings.Settings

      ···
        46
        50
         	return nil

      
        47
        51
         }

      
        48
        52
         

      
        49
        
        -// analysisFor returns the cached analysis for an open doc, rebuilds when the doc or a file it inclues changed.

      
        
        53
        +// analysisFor returns the cached analysis for an open doc, rebuilding when the doc or a file it includes changed.

      
        50
        54
         func (s *server) analysisFor(u uri.URI) *analyzer.Analysis {

      
        51
        55
         	s.mu.RLock()

      
        52
        56
         	state, ok := s.openDocs[u]

      ···
        55
        59
         		return nil

      
        56
        60
         	}

      
        57
        61
         	if !state.dirty {

      
        58
        
        -		an := state.analysis

      
        
        62
        +		an := state.cache.analysis

      
        59
        63
         		s.mu.RUnlock()

      
        60
        64
         		return an

      
        61
        65
         	}

      ···
        68
        72
         	s.mu.Lock()

      
        69
        73
         	state, ok = s.openDocs[u]

      
        70
        74
         	if !ok || state.version != version {

      
        71
        
        -		// editot or closed while building. doc stays dirty so te request rebuilds

      
        
        75
        +		// editor closed or edited while building; the doc stays dirty so the next request rebuilds

      
        72
        76
         		s.mu.Unlock()

      
        73
        77
         		return an

      
        74
        78
         	}

      
        75
        
        -	state.analysis = an

      
        
        79
        +	state.cache = newAnalysisCache(an)

      
        76
        80
         	state.dirty = false

      
        77
        
        -	state.paths = make(map[string]bool, len(an.Files))

      
        78
        
        -	for _, pf := range an.Files {

      
        79
        
        -		state.paths[journal.CanonicalPath(pf.Path)] = true

      
        80
        
        -	}

      
        81
        81
         	s.openDocs[u] = state

      
        82
        82
         	s.mu.Unlock()

      
        83
        83
         	return an

      ···
        93
        93
         	if err := s.applySettings(ctx, params.InitializationOptions); err != nil {

      
        94
        94
         		return nil, err

      
        95
        95
         	}

      
        
        96
        +	td := params.Capabilities.TextDocument

      
        96
        97
         	full := protocol.SemanticTokensOptionsFull(protocol.Boolean(true))

      
        97
        
        -	if td := params.Capabilities.TextDocument; td != nil {

      
        
        98
        +	if td != nil {

      
        98
        99
         		if fd, ok := td.SemanticTokens.Requests.Full.(*protocol.ClientSemanticTokensRequestFullDelta); ok && fd.Delta != nil && *fd.Delta {

      
        99
        100
         			full = &protocol.SemanticTokensFullDelta{Delta: new(true)}

      
        100
        101
         		}

      
        101
        102
         	}

      
        102
        103
         

      
        
        104
        +	// RenameOptions may only be specified when the client states prepare support.

      
        
        105
        +	renameProvider := protocol.RenameProvider(&protocol.RenameOptions{PrepareProvider: new(true)})

      
        
        106
        +	if td == nil || td.Rename == nil || td.Rename.PrepareSupport == nil || !*td.Rename.PrepareSupport {

      
        
        107
        +		renameProvider = protocol.Boolean(true)

      
        
        108
        +	}

      
        
        109
        +

      
        
        110
        +	s.stateMu.Lock()

      
        
        111
        +	if s.state != stateNew {

      
        
        112
        +		s.stateMu.Unlock()

      
        
        113
        +		return nil, errInitializeOnce

      
        
        114
        +	}

      
        
        115
        +	s.state = stateInitialized

      
        
        116
        +	s.stateMu.Unlock()

      
        
        117
        +	if p := params.ProcessID; p != nil {

      
        
        118
        +		s.watchParent(*p)

      
        
        119
        +	}

      
        
        120
        +

      
        103
        121
         	return &protocol.InitializeResult{

      
        104
        122
         		ServerInfo: protocol.ServerInfo{

      
        105
        123
         			Name:    s.name,

      ···
        114
        132
         			DocumentSymbolProvider:     protocol.Boolean(true),

      
        115
        133
         			FoldingRangeProvider:       protocol.Boolean(true),

      
        116
        134
         			SelectionRangeProvider:     protocol.Boolean(true),

      
        117
        
        -			RenameProvider: &protocol.RenameOptions{

      
        118
        
        -				PrepareProvider: new(true),

      
        119
        
        -			},

      
        
        135
        +			RenameProvider:             renameProvider,

      
        120
        136
         			CompletionProvider: &protocol.CompletionOptions{

      
        121
        137
         				TriggerCharacters: []string{":", "@"},

      
        122
        138
         			},

      ···
        164
        180
         }

      
        165
        181
         

      
        166
        182
         func (s *server) Shutdown(ctx context.Context) error {

      
        
        183
        +	s.stateMu.Lock()

      
        
        184
        +	if s.state == stateInitialized {

      
        
        185
        +		s.state = stateShutdownRequested

      
        
        186
        +	}

      
        
        187
        +	s.stateMu.Unlock()

      
        167
        188
         	return nil

      
        168
        189
         }

      
        169
        190
         

      
        170
        
        -func (s *server) Exit(ctx context.Context) error {

      
        
        191
        +// Exit records the exit code the LSP spec prescribes — 0 after a shutdown

      
        
        192
        +// request, 1 otherwise — and closes the connection to end the session. The

      
        
        193
        +// close runs in its own goroutine: called from inside the exit-notification

      
        
        194
        +// handler it cannot block on the connection draining (see [jsonrpc2.Conn.Close]).

      
        
        195
        +// Exit honors an exit that never saw initialize, and is safe to call

      
        
        196
        +// concurrently from the exit notification and watchParent: the stateMu guard

      
        
        197
        +// below makes the transition a once-only, and an exit that lost the race to

      
        
        198
        +// another exit is a no-op.

      
        
        199
        +func (s *server) Exit(context.Context) error {

      
        
        200
        +	s.stateMu.Lock()

      
        
        201
        +	if s.state < stateExited {

      
        
        202
        +		if s.state == stateShutdownRequested {

      
        
        203
        +			s.state = stateExitedAfterShutdown

      
        
        204
        +		} else {

      
        
        205
        +			s.state = stateExited

      
        
        206
        +		}

      
        
        207
        +	}

      
        
        208
        +	s.stateMu.Unlock()

      
        
        209
        +	go s.conn.Close()

      
        171
        210
         	return nil

      
        172
        211
         }

      
        173
        212
         

      
M internal/lsp/server_test.go
···
        7
        7
         	"strings"

      
        8
        8
         	"sync"

      
        9
        9
         	"testing"

      
        10
        
        -	"time"

      
        11
        10
         

      
        12
        11
         	"go.lsp.dev/protocol"

      
        13
        12
         	"go.lsp.dev/uri"

      ···
        92
        91
         	open(a, aContent)

      
        93
        92
         	open(b, bContent)

      
        94
        93
         

      
        95
        
        -	waitFor(t, "diagnostics for the unbalanced transaction", func() bool {

      
        96
        
        -		da, _ := capture.lastDiags(a)

      
        97
        
        -		return len(da) != 0

      
        98
        
        -	})

      
        
        94
        +	// publishDiagnostics is invoked directly so the test is deterministic: the

      
        
        95
        +	// didOpen/didChange handlers debounce publishing by 200ms in production.

      
        
        96
        +	srv.server.publishDiagnostics(t.Context())

      
        
        97
        +	da, _ := capture.lastDiags(a)

      
        
        98
        +	if len(da) == 0 {

      
        
        99
        +		t.Fatal("expected diagnostics for the unbalanced transaction")

      
        
        100
        +	}

      
        99
        101
         

      
        100
        102
         	aEdited := "account expenses:food\naccount assets:cash\naccount assets:bank\ncommodity $\npayee test\n\n2024-01-01 * test\n    expenses:food  $20.00\n    assets:cash  $-10.00\n    assets:bank  $-10.00\n"

      
        101
        103
         	if err := srv.server.DidChange(t.Context(), &protocol.DidChangeTextDocumentParams{

      ···
        110
        112
         		t.Fatalf("didChange a: %v", err)

      
        111
        113
         	}

      
        112
        114
         

      
        113
        
        -	waitFor(t, "a diagnostics to clear after the edit", func() bool {

      
        114
        
        -		da, _ := capture.lastDiags(a)

      
        115
        
        -		return len(da) == 0

      
        116
        
        -	})

      
        
        115
        +	srv.server.publishDiagnostics(t.Context())

      
        
        116
        +	if da, _ := capture.lastDiags(a); len(da) != 0 {

      
        
        117
        +		t.Errorf("diagnostics not cleared after the edit: %v", da)

      
        
        118
        +	}

      
        117
        119
         

      
        118
        120
         	if err := srv.server.DidClose(t.Context(), &protocol.DidCloseTextDocumentParams{

      
        119
        121
         		TextDocument: protocol.TextDocumentIdentifier{URI: a},

      ···
        137
        139
         	a1 := srv.server.analysisFor(uBase)

      
        138
        140
         

      
        139
        141
         	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"))

      
        140
        
        -	if err := srv.server.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{

      
        
        142
        +	if err := srv.server.DidChangeWatchedFiles(t.Context(), &protocol.DidChangeWatchedFilesParams{

      
        141
        143
         		Changes: []protocol.FileEvent{{URI: uBase, Type: protocol.FileChangeTypeChanged}},

      
        142
        144
         	}); err != nil {

      
        143
        145
         		t.Fatalf("didChangeWatchedFiles: %v", err)

      ···
        158
        160
         	srv := newServer(t)

      
        159
        161
         	srv.server.client = &captureClient{}

      
        160
        162
         	uMain := uri.File(main)

      
        161
        
        -	if err := srv.server.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{

      
        
        163
        +	if err := srv.server.DidOpen(t.Context(), &protocol.DidOpenTextDocumentParams{

      
        162
        164
         		TextDocument: protocol.TextDocumentItem{URI: uMain, LanguageID: "journal", Version: 1, Text: "include base.journal\n"},

      
        163
        165
         	}); err != nil {

      
        164
        166
         		t.Fatalf("didOpen: %v", err)

      ···
        171
        173
         

      
        172
        174
         	// base changes on disk, outside the editor

      
        173
        175
         	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"))

      
        174
        
        -	if err := srv.server.DidChangeWatchedFiles(context.Background(), &protocol.DidChangeWatchedFilesParams{

      
        
        176
        +	if err := srv.server.DidChangeWatchedFiles(t.Context(), &protocol.DidChangeWatchedFilesParams{

      
        175
        177
         		Changes: []protocol.FileEvent{{URI: uri.File(base), Type: protocol.FileChangeTypeChanged}},

      
        176
        178
         	}); err != nil {

      
        177
        179
         		t.Fatalf("didChangeWatchedFiles: %v", err)

      ···
        291
        293
         		}

      
        292
        294
         	}

      
        293
        295
         	return nil, false

      
        294
        
        -}

      
        295
        
        -

      
        296
        
        -func waitFor(t *testing.T, what string, cond func() bool) {

      
        297
        
        -	t.Helper()

      
        298
        
        -	deadline := time.Now().Add(5 * time.Second)

      
        299
        
        -	for time.Now().Before(deadline) {

      
        300
        
        -		if cond() {

      
        301
        
        -			return

      
        302
        
        -		}

      
        303
        
        -		time.Sleep(10 * time.Millisecond)

      
        304
        
        -	}

      
        305
        
        -	t.Fatalf("timed out waiting for %s", what)

      
        306
        296
         }

      
        307
        297
         

      
        308
        298
         func newServer(tb testing.TB) Server {

      
M internal/lsp/testdata/semantic-journal.txtar
···
        136
        136
         14:0+10 class

      
        137
        137
         14:11+10 class

      
        138
        138
         14:22+9 property

      
        139
        
        -15:2+2 operator

      
        
        139
        +15:2+1 operator

      
        140
        140
         15:4+11 namespace

      
        141
        141
         15:17+1 type

      
        142
        142
         15:18+2 number

      
        143
        
        -16:2+2 operator

      
        
        143
        +16:2+1 operator

      
        144
        144
         16:4+1 operator abstract

      
        145
        145
         16:5+11 namespace

      
        146
        146
         16:16+1 operator abstract

      ···
        193
        193
         37:7+1 type

      
        194
        194
         37:8+3 number negative

      
        195
        195
         39:0+10 class

      
        196
        
        -39:11+2 operator

      
        
        196
        +39:11+1 operator

      
        197
        197
         39:13+6 string

      
        198
        198
         39:20+4 property

      
        199
        199
         39:27+6 property

      ···
        231
        231
         48:31+3 number

      
        232
        232
         49:4+23 comment

      
        233
        233
         51:0+10 class

      
        234
        
        -51:11+2 operator

      
        
        234
        +51:11+1 operator

      
        235
        235
         51:13+4 property

      
        236
        236
         51:19+13 comment

      
        237
        237
         52:4+13 namespace

      ···
        239
        239
         53:21+1 type

      
        240
        240
         53:22+3 number

      
        241
        241
         55:0+10 class

      
        242
        
        -55:11+2 operator

      
        
        242
        +55:11+1 operator

      
        243
        243
         55:13+4 property

      
        244
        244
         56:4+1 namespace

      
        245
        245
         56:7+1 type

      ···
        313
        313
         72:11+6 string

      
        314
        314
         72:18+5 property

      
        315
        315
         74:0+10 class

      
        316
        
        -74:11+2 operator

      
        
        316
        +74:11+1 operator

      
        317
        317
         74:13+4 property

      
        318
        318
         75:2+6 namespace

      
        319
        319
         76:2+8 namespace

      
        320
        320
         76:12+14 comment

      
        321
        321
         78:0+10 class

      
        322
        
        -78:11+2 operator

      
        
        322
        +78:11+1 operator

      
        323
        323
         78:13+4 property

      
        324
        324
         80:0+10 class

      
        325
        325
         80:11+2 property

      
M internal/lsp/testdata/semantic-unparseable.txtar
···
        20
        20
         3:0+14 comment

      
        21
        21
         4:0+11 comment

      
        22
        22
         5:0+10 class

      
        23
        
        -5:11+2 operator

      
        
        23
        +5:11+1 operator

      
        24
        24
         5:13+6 string

      
        25
        25
         5:20+5 property

      
        26
        26
         6:2+13 namespace

      
M internal/lsp/textdocument_hover_test.go
···
        1
        1
         package lsp

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"context"

      
        5
        4
         	"fmt"

      
        6
        5
         	"strings"

      
        7
        6
         	"testing"

      ···
        45
        44
         

      
        46
        45
         func TestServer_Hover_DocumentNotFound(t *testing.T) {

      
        47
        46
         	srv := newServer(t)

      
        48
        
        -	res, err := srv.server.Hover(context.Background(), &protocol.HoverParams{

      
        
        47
        +	res, err := srv.server.Hover(t.Context(), &protocol.HoverParams{

      
        49
        48
         		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")},

      
        50
        49
         		Position:     protocol.Position{Line: 0, Character: 0},

      
        51
        50
         	})

      
M internal/lsp/textdocument_references_test.go
···
        1
        1
         package lsp

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"context"

      
        5
        4
         	"fmt"

      
        6
        5
         	"path/filepath"

      
        7
        6
         	"strings"

      ···
        17
        16
         

      
        18
        17
         func TestServer_References_DocumentNotFound(t *testing.T) {

      
        19
        18
         	srv := newServer(t)

      
        20
        
        -	res, err := srv.server.References(context.Background(), &protocol.ReferenceParams{

      
        
        19
        +	res, err := srv.server.References(t.Context(), &protocol.ReferenceParams{

      
        21
        20
         		TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///nonexistent.journal")},

      
        22
        21
         		Position:     protocol.Position{Line: 0, Character: 0},

      
        23
        22
         	})

      
M internal/lsp/textdocument_sync.go
···
        55
        55
         	text       string

      
        56
        56
         	version    int32

      
        57
        57
         	languageID protocol.LanguageKind

      
        58
        
        -	paths      map[string]bool    // canonical paths of every file in the cached analysis

      
        59
        58
         	lineIdx    *lsputil.LineIndex // cached line index for the text

      
        60
        59
         

      
        61
        
        -	analysis *analyzer.Analysis // cached analysis, nil until first build

      
        62
        
        -	sem      *semCache          // semantic token cache, nil until first tokenize

      
        63
        
        -	dirty    bool               // true while the cached analysis may not reflect the current text

      
        
        60
        +	dirty bool           // true while the cached analysis may not reflect the current text

      
        
        61
        +	cache *analysisCache // nil until first build

      
        
        62
        +	sem   *semCache      // nil until first tokenize

      
        
        63
        +}

      
        
        64
        +

      
        
        65
        +type analysisCache struct {

      
        
        66
        +	analysis *analyzer.Analysis

      
        
        67
        +	fileIdx  map[string]*lsputil.LineIndex

      
        
        68
        +	paths    map[string]bool

      
        
        69
        +}

      
        
        70
        +

      
        
        71
        +func newAnalysisCache(an *analyzer.Analysis) *analysisCache {

      
        
        72
        +	paths := make(map[string]bool, len(an.Files))

      
        
        73
        +	for _, pf := range an.Files {

      
        
        74
        +		paths[journal.CanonicalPath(pf.Path)] = true

      
        
        75
        +	}

      
        
        76
        +	return &analysisCache{analysis: an, paths: paths, fileIdx: buildFileIdx(an)}

      
        
        77
        +}

      
        
        78
        +

      
        
        79
        +func buildFileIdx(an *analyzer.Analysis) map[string]*lsputil.LineIndex {

      
        
        80
        +	idx := make(map[string]*lsputil.LineIndex, len(an.Files))

      
        
        81
        +	for _, pf := range an.Files {

      
        
        82
        +		idx[pf.Path] = lsputil.NewLineIndex(string(pf.Src))

      
        
        83
        +	}

      
        
        84
        +	return idx

      
        64
        85
         }

      
        65
        86
         

      
        66
        87
         func (s *server) openDoc(u uri.URI, text string, version int32, langID protocol.LanguageKind) {

      ···
        114
        135
         		state.lineIdx = lsputil.NewLineIndex(state.text)

      
        115
        136
         	}

      
        116
        137
         

      
        117
        
        -	state.analysis = nil

      
        
        138
        +	state.cache = nil

      
        118
        139
         	state.dirty = true

      
        119
        140
         	if incremental {

      
        120
        141
         		state.sem.pending = &edit

      ···
        133
        154
         	canon := journal.CanonicalPath(u.Path())

      
        134
        155
         	s.mu.Lock()

      
        135
        156
         	for du, dstate := range s.openDocs {

      
        136
        
        -		if dstate.paths[canon] {

      
        
        157
        +		if dstate.cache == nil {

      
        
        158
        +			continue

      
        
        159
        +		}

      
        
        160
        +		if dstate.cache.paths[canon] {

      
        137
        161
         			dstate.dirty = true

      
        138
        162
         			s.openDocs[du] = dstate

      
        139
        163
         		}

      
M internal/lsp/workspace_symbol_test.go
···
        1
        1
         package lsp

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"context"

      
        5
        4
         	"fmt"

      
        6
        5
         	"path/filepath"

      
        7
        6
         	"strings"

      ···
        37
        36
         	u := uri.URI("file:///test.journal")

      
        38
        37
         	srv.server.openDoc(u, "account expenses:food\n", 1, "journal")

      
        39
        38
         

      
        40
        
        -	res, err := srv.server.Symbols(context.Background(), &protocol.WorkspaceSymbolParams{Query: ""})

      
        
        39
        +	res, err := srv.server.Symbols(t.Context(), &protocol.WorkspaceSymbolParams{Query: ""})

      
        41
        40
         	if err != nil {

      
        42
        41
         		t.Fatal(err)

      
        43
        42
         	}

      
M journal/parser/parser.go
···
        836
        836
         		st = ast.StatusPending

      
        837
        837
         	}

      
        838
        838
         	if st != ast.StatusNone {

      
        
        839
        +		sp := p.cur.Span

      
        839
        840
         		p.advance()

      
        840
        841
         		p.skipWhitespace()

      
        
        842
        +		return st, sp

      
        841
        843
         	}

      
        842
        844
         	return st, p.span(s)

      
        843
        845
         }