all repos

clerk @ 2ca3278

missing tooling for ledger/hledger
22 files changed, 609 insertions(+), 227 deletions(-)
lsp: cache per-document analyses with include-aware invalidation

Rebuild a document's analysis only when it or a file it includes changes.
Loader gains a bounded parse cache and a ContentProvider so open buffers
feed include resolution; diagnostics lint only dirty documents.
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-13 17:06:52 +0300
Authored at: 2026-08-13 13:54:45 +0300
Change ID: nuxznvpsvxrlnvnkpzlxopnmwtruxkpp
Parent: 8921805
M internal/lsp/diagnostics.go
···
        40
        40
         		return

      
        41
        41
         	}

      
        42
        42
         

      
        43
        
        -	a := s.buildAnalysis()

      
        44
        
        -	if a == nil {

      
        45
        
        -		s.log.Debug("no files in workspace")

      
        46
        
        -		return

      
        
        43
        +	s.mu.Lock()

      
        
        44
        +	var dirtyURIs []uri.URI

      
        
        45
        +	for u, state := range s.openDocs {

      
        
        46
        +		if state.dirty {

      
        
        47
        +			dirtyURIs = append(dirtyURIs, u)

      
        
        48
        +		}

      
        47
        49
         	}

      
        48
        
        -

      
        49
        
        -	activePaths := make(map[string]bool, len(a.Files))

      
        50
        
        -	for _, pf := range a.Files {

      
        51
        
        -		activePaths[pf.Path] = true

      
        52
        
        -	}

      
        53
        
        -

      
        54
        
        -	if ctx.Err() != nil {

      
        
        50
        +	s.mu.Unlock()

      
        
        51
        +	if len(dirtyURIs) == 0 {

      
        
        52
        +		s.log.Debug("no dirty files")

      
        55
        53
         		return

      
        56
        54
         	}

      
        57
        55
         

      
        58
        
        -	finds := dedupFinds(s.linter.Run(a))

      
        59
        
        -

      
        60
        
        -	s.mu.Lock()

      
        61
        
        -	s.current = a

      
        62
        
        -	s.mu.Unlock()

      
        
        56
        +	// Rebuild every dirty doc and publish the union of their files;

      
        
        57
        +	// the same included file may appear in several trees and must be published once.

      
        
        58
        +	var finds []linter.Find

      
        
        59
        +	paths := make(map[string]bool)

      
        
        60
        +	for _, u := range dirtyURIs {

      
        
        61
        +		a := s.analysisFor(u)

      
        
        62
        +		if a == nil {

      
        
        63
        +			continue

      
        
        64
        +		}

      
        
        65
        +		for _, pf := range a.Files {

      
        
        66
        +			paths[pf.Path] = true

      
        
        67
        +		}

      
        
        68
        +		finds = append(finds, s.linter.Run(a)...)

      
        
        69
        +	}

      
        63
        70
         

      
        64
        71
         	if ctx.Err() != nil {

      
        65
        72
         		return

      
        66
        73
         	}

      
        67
        74
         

      
        68
        
        -	diagsByFile := s.groupFindsByFile(finds)

      
        69
        
        -

      
        70
        
        -	for fpath := range activePaths {

      
        
        75
        +	diagsByFile := s.groupFindsByFile(dedupFinds(finds))

      
        
        76
        +	for fpath := range paths {

      
        71
        77
         		if ctx.Err() != nil {

      
        72
        78
         			return

      
        73
        79
         		}

      ···
        79
        85
         		}

      
        80
        86
         	}

      
        81
        87
         

      
        82
        
        -	s.log.Debug("diagnostics published", "files", len(a.Files), "findings", len(finds))

      
        
        88
        +	s.log.Debug("diagnostics published", "files", len(paths), "findings", len(finds))

      
        83
        89
         }

      
        84
        90
         

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

      
M internal/lsp/diagnostics_test.go
···
        3
        3
         import (

      
        4
        4
         	"testing"

      
        5
        5
         

      
        6
        
        -	"go.lsp.dev/uri"

      
        
        6
        +	"olexsmir.xyz/clerk/internal/analyzer"

      
        
        7
        +	"olexsmir.xyz/clerk/journal"

      
        7
        8
         )

      
        8
        9
         

      
        9
        10
         func BenchmarkDiagnostics(b *testing.B) {

      
        10
        11
         	content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        11
        
        -

      
        12
        12
         	srv := NewServer("test")

      
        13
        
        -	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")

      
        14
        13
         

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

      
        15
        15
         	b.ReportAllocs()

      
        16
        16
         	b.ResetTimer()

      
        17
        17
         	for b.Loop() {

      
        18
        
        -		// Per-edit cost: re-resolve (parse) + lint + group findings.

      
        19
        
        -		a := srv.server.buildAnalysis()

      
        20
        
        -		finds := dedupFinds(srv.server.linter.Run(a))

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

      
        
        19
        +		finds := dedupFinds(srv.server.linter.Run(an))

      
        21
        20
         		_ = srv.server.groupFindsByFile(finds)

      
        22
        21
         	}

      
        23
        22
         }

      
M internal/lsp/lsp.go
···
        20
        20
         type Server struct{ server *server }

      
        21
        21
         

      
        22
        22
         func NewServer(version string) Server {

      
        23
        
        -	var logger *slog.Logger

      
        24
        
        -	logFile, err := openLogFile()

      
        25
        
        -	if err == nil {

      
        
        23
        +	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

      
        
        24
        +	if logFile, err := openLogFile(); err == nil {

      
        26
        25
         		logger = slog.New(slog.NewTextHandler(logFile, nil))

      
        27
        
        -	} else {

      
        28
        
        -		logger = slog.New(slog.NewTextHandler(os.Stderr, nil))

      
        29
        26
         	}

      
        30
        27
         

      
        31
        
        -	return Server{

      
        32
        
        -		&server{

      
        33
        
        -			name:    "clerk",

      
        34
        
        -			version: version,

      
        
        28
        +	srv := &server{

      
        
        29
        +		name:    "clerk",

      
        
        30
        +		version: version,

      
        35
        31
         

      
        36
        
        -			openDocs: make(map[uri.URI]docState),

      
        
        32
        +		openDocs: make(map[uri.URI]docState),

      
        37
        33
         

      
        38
        
        -			config:  DefaultConfig,

      
        39
        
        -			linter:  linter.NewLinter(linter.Rules),

      
        40
        
        -			loader:  journal.NewLoader(),

      
        41
        
        -			printer: printer.DefaultConfig,

      
        
        34
        +		config:  DefaultConfig,

      
        
        35
        +		linter:  linter.NewLinter(linter.Rules),

      
        
        36
        +		loader:  journal.NewLoader(),

      
        
        37
        +		printer: printer.DefaultConfig,

      
        42
        38
         

      
        43
        
        -			log: logger,

      
        44
        
        -		},

      
        45
        
        -	}

      
        46
        
        -}

      
        47
        
        -

      
        48
        
        -func openLogFile() (*os.File, error) {

      
        49
        
        -	dir, err := xdg.StateDir()

      
        50
        
        -	if err != nil {

      
        51
        
        -		return nil, err

      
        52
        
        -	}

      
        53
        
        -	dir = filepath.Join(dir, "clerk")

      
        54
        
        -	if err := os.MkdirAll(dir, 0o700); err != nil {

      
        55
        
        -		return nil, err

      
        
        39
        +		log: logger,

      
        56
        40
         	}

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

      
        
        41
        +	srv.loader.ContentProvider = srv.bufferContent

      
        
        42
        +	return Server{srv}

      
        58
        43
         }

      
        59
        44
         

      
        60
        45
         func (s *Server) Run(ctx context.Context, stdin io.ReadCloser, stdout io.WriteCloser) error {

      ···
        73
        58
         	return conn.Err()

      
        74
        59
         }

      
        75
        60
         

      
        
        61
        +// bufferContent returns the open buffer text for a path, if any.

      
        
        62
        +// called by the loader during include resolution; must NOT hold the loader lock.

      
        
        63
        +func (s *server) bufferContent(path string) ([]byte, bool) {

      
        
        64
        +	s.mu.Lock()

      
        
        65
        +	defer s.mu.Unlock()

      
        
        66
        +	st, ok := s.openDocs[uri.File(path)]

      
        
        67
        +	return []byte(st.text), ok

      
        
        68
        +}

      
        
        69
        +

      
        76
        70
         type readWriterCloser struct {

      
        77
        71
         	io.Reader

      
        78
        72
         	io.Writer

      
        79
        73
         	io.Closer

      
        80
        74
         }

      
        
        75
        +

      
        
        76
        +func openLogFile() (*os.File, error) {

      
        
        77
        +	dir, err := xdg.StateDir()

      
        
        78
        +	if err != nil {

      
        
        79
        +		return nil, err

      
        
        80
        +	}

      
        
        81
        +	dir = filepath.Join(dir, "clerk")

      
        
        82
        +	if err := os.MkdirAll(dir, 0o700); err != nil {

      
        
        83
        +		return nil, err

      
        
        84
        +	}

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

      
        
        86
        +}

      
M internal/lsp/server.go
···
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        5
         	"log/slog"

      
        6
        
        -	"maps"

      
        7
        6
         	"sync"

      
        8
        7
         

      
        9
        8
         	"go.lsp.dev/protocol"

      ···
        36
        35
         	config Config

      
        37
        36
         }

      
        38
        37
         

      
        39
        
        -func (s *server) analysis() *analyzer.Analysis {

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

      
        
        39
        +func (s *server) analysisFor(u uri.URI) *analyzer.Analysis {

      
        40
        40
         	s.mu.Lock()

      
        41
        
        -	a := s.current

      
        42
        
        -	s.mu.Unlock()

      
        43
        
        -	if a != nil {

      
        44
        
        -		return a

      
        
        41
        +	state, ok := s.openDocs[u]

      
        
        42
        +	if !ok {

      
        
        43
        +		s.mu.Unlock()

      
        
        44
        +		return nil

      
        45
        45
         	}

      
        46
        
        -	return s.buildAnalysis()

      
        47
        
        -}

      
        48
        
        -

      
        49
        
        -func (s *server) buildAnalysis() *analyzer.Analysis {

      
        50
        
        -	s.mu.Lock()

      
        51
        
        -	docs := make(map[uri.URI]docState, len(s.openDocs))

      
        52
        
        -	maps.Copy(docs, s.openDocs)

      
        
        46
        +	if !state.dirty {

      
        
        47
        +		an := state.analysis

      
        
        48
        +		s.mu.Unlock()

      
        
        49
        +		return an

      
        
        50
        +	}

      
        
        51
        +	text := state.text

      
        
        52
        +	version := state.version

      
        53
        53
         	s.mu.Unlock()

      
        54
        54
         

      
        55
        
        -	var a *analyzer.Analysis

      
        56
        
        -	for duri, state := range docs {

      
        57
        
        -		rj := s.loader.ResolveBytes(duri.Path(), []byte(state.text))

      
        58
        
        -		if a == nil {

      
        59
        
        -			a = analyzer.Build(rj)

      
        60
        
        -		}

      
        
        55
        +	an := analyzer.Build(s.loader.ResolveBytes(u.Path(), []byte(text)))

      
        
        56
        +

      
        
        57
        +	s.mu.Lock()

      
        
        58
        +	state, ok = s.openDocs[u]

      
        
        59
        +	if !ok || state.version != version {

      
        
        60
        +		// editot or closed while building. doc stays dirty so te request rebuilds

      
        
        61
        +		s.mu.Unlock()

      
        
        62
        +		return an

      
        
        63
        +	}

      
        
        64
        +	state.analysis = an

      
        
        65
        +	state.dirty = false

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

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

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

      
        61
        69
         	}

      
        62
        
        -	return a

      
        
        70
        +	s.openDocs[u] = state

      
        
        71
        +	s.mu.Unlock()

      
        
        72
        +	return an

      
        63
        73
         }

      
        64
        74
         

      
        65
        75
         func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"path/filepath"

      
        
        6
        +	"slices"

      
        
        7
        +	"sync"

      
        
        8
        +	"testing"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"go.lsp.dev/protocol"

      
        
        12
        +	"go.lsp.dev/uri"

      
        
        13
        +

      
        
        14
        +	"olexsmir.xyz/clerk/internal/testutil"

      
        
        15
        +)

      
        
        16
        +

      
        
        17
        +func TestAnalysisFor_CachedAndRebuilt(t *testing.T) {

      
        
        18
        +	u := uri.File(filepath.Join(t.TempDir(), "a.journal"))

      
        
        19
        +	srv := NewServer("test")

      
        
        20
        +	srv.server.openDoc(u, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")

      
        
        21
        +

      
        
        22
        +	a1 := srv.server.analysisFor(u)

      
        
        23
        +	if a1 == nil {

      
        
        24
        +		t.Fatal("analysisFor returned nil")

      
        
        25
        +	}

      
        
        26
        +	if a2 := srv.server.analysisFor(u); a2 != a1 {

      
        
        27
        +		t.Error("cached analysis not reused")

      
        
        28
        +	}

      
        
        29
        +

      
        
        30
        +	srv.server.updateDoc(u, 2, []protocol.TextDocumentContentChangeEvent{

      
        
        31
        +		&protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-02 t\n    expenses:travel  $20\n    assets:cash\n"},

      
        
        32
        +	})

      
        
        33
        +	a3 := srv.server.analysisFor(u)

      
        
        34
        +	if a3 == a1 {

      
        
        35
        +		t.Error("edit did not rebuild the analysis")

      
        
        36
        +	}

      
        
        37
        +	if !slices.Contains(a3.AccountNames, "expenses:travel") || slices.Contains(a3.AccountNames, "expenses:food") {

      
        
        38
        +		t.Errorf("stale accounts after edit: %v", a3.AccountNames)

      
        
        39
        +	}

      
        
        40
        +}

      
        
        41
        +

      
        
        42
        +func TestAnalysisFor_DependentDirty(t *testing.T) {

      
        
        43
        +	dir := t.TempDir()

      
        
        44
        +	base := filepath.Join(dir, "base.journal")

      
        
        45
        +	main := filepath.Join(dir, "main.journal")

      
        
        46
        +	testutil.WriteFile(t, base, []byte("2024-01-01 t\n    expenses:food  $10\n    assets:cash\n"))

      
        
        47
        +	testutil.WriteFile(t, main, []byte("include base.journal\n"))

      
        
        48
        +

      
        
        49
        +	srv := NewServer("test")

      
        
        50
        +	uMain, uBase := uri.File(main), uri.File(base)

      
        
        51
        +	srv.server.openDoc(uMain, "include base.journal\n", 1, "journal")

      
        
        52
        +	srv.server.openDoc(uBase, "2024-01-01 t\n    expenses:food  $10\n    assets:cash\n", 1, "journal")

      
        
        53
        +

      
        
        54
        +	aMain := srv.server.analysisFor(uMain)

      
        
        55
        +	if !slices.Contains(aMain.AccountNames, "assets:cash") {

      
        
        56
        +		t.Fatalf("main analysis missing included account: %v", aMain.AccountNames)

      
        
        57
        +	}

      
        
        58
        +

      
        
        59
        +	srv.server.updateDoc(uBase, 2, []protocol.TextDocumentContentChangeEvent{

      
        
        60
        +		&protocol.TextDocumentContentChangeWholeDocument{Text: "2024-01-01 t\n    expenses:food  $10\n    assets:bank\n"},

      
        
        61
        +	})

      
        
        62
        +	aMain2 := srv.server.analysisFor(uMain)

      
        
        63
        +	if !slices.Contains(aMain2.AccountNames, "assets:bank") {

      
        
        64
        +		t.Errorf("dependent analysis not rebuilt with new buffer content: %v", aMain2.AccountNames)

      
        
        65
        +	}

      
        
        66
        +}

      
        
        67
        +

      
        
        68
        +func TestServer_Diagnostics(t *testing.T) {

      
        
        69
        +	dir := t.TempDir()

      
        
        70
        +	a := uri.File(filepath.Join(dir, "a.journal"))

      
        
        71
        +	b := uri.File(filepath.Join(dir, "b.journal"))

      
        
        72
        +

      
        
        73
        +	aContent := "account expenses:food\naccount assets:cash\ncommodity $\npayee test\n\n2024-01-01 * test\n    expenses:food  $10.00\n    assets:cash  $5.00\n"

      
        
        74
        +	bContent := "include a.journal\n"

      
        
        75
        +	testutil.WriteFile(t, a.Path(), []byte(aContent))

      
        
        76
        +	testutil.WriteFile(t, b.Path(), []byte(bContent))

      
        
        77
        +

      
        
        78
        +	srv := NewServer("test")

      
        
        79
        +	capture := &captureClient{}

      
        
        80
        +	srv.server.client = capture

      
        
        81
        +

      
        
        82
        +	open := func(u uri.URI, content string) {

      
        
        83
        +		t.Helper()

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

      
        
        85
        +			TextDocument: protocol.TextDocumentItem{URI: u, LanguageID: "journal", Version: 1, Text: content},

      
        
        86
        +		}); err != nil {

      
        
        87
        +			t.Fatalf("didOpen %s: %v", u, err)

      
        
        88
        +		}

      
        
        89
        +	}

      
        
        90
        +	open(a, aContent)

      
        
        91
        +	open(b, bContent)

      
        
        92
        +

      
        
        93
        +	waitFor(t, "diagnostics for the unbalanced transaction", func() bool {

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

      
        
        95
        +		return len(da) != 0

      
        
        96
        +	})

      
        
        97
        +

      
        
        98
        +	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"

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

      
        
        100
        +		TextDocument: protocol.VersionedTextDocumentIdentifier{

      
        
        101
        +			TextDocumentIdentifier: protocol.TextDocumentIdentifier{URI: a},

      
        
        102
        +			Version:                2,

      
        
        103
        +		},

      
        
        104
        +		ContentChanges: []protocol.TextDocumentContentChangeEvent{

      
        
        105
        +			&protocol.TextDocumentContentChangeWholeDocument{Text: aEdited},

      
        
        106
        +		},

      
        
        107
        +	}); err != nil {

      
        
        108
        +		t.Fatalf("didChange a: %v", err)

      
        
        109
        +	}

      
        
        110
        +

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

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

      
        
        113
        +		return len(da) == 0

      
        
        114
        +	})

      
        
        115
        +

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

      
        
        117
        +		TextDocument: protocol.TextDocumentIdentifier{URI: a},

      
        
        118
        +	}); err != nil {

      
        
        119
        +		t.Fatalf("didClose a: %v", err)

      
        
        120
        +	}

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

      
        
        122
        +		t.Errorf("diagnostics not cleared on close: %v", da)

      
        
        123
        +	}

      
        
        124
        +}

      
        
        125
        +

      
        
        126
        +type captureClient struct {

      
        
        127
        +	protocol.Client

      
        
        128
        +	mu   sync.Mutex

      
        
        129
        +	diag []protocol.PublishDiagnosticsParams

      
        
        130
        +}

      
        
        131
        +

      
        
        132
        +func (c *captureClient) PublishDiagnostics(_ context.Context, params *protocol.PublishDiagnosticsParams) error {

      
        
        133
        +	c.mu.Lock()

      
        
        134
        +	c.diag = append(c.diag, *params)

      
        
        135
        +	c.mu.Unlock()

      
        
        136
        +	return nil

      
        
        137
        +}

      
        
        138
        +

      
        
        139
        +func (c *captureClient) lastDiags(u uri.URI) ([]protocol.Diagnostic, bool) {

      
        
        140
        +	c.mu.Lock()

      
        
        141
        +	defer c.mu.Unlock()

      
        
        142
        +	for _, v := range slices.Backward(c.diag) {

      
        
        143
        +		if v.URI == u {

      
        
        144
        +			return v.Diagnostics, true

      
        
        145
        +		}

      
        
        146
        +	}

      
        
        147
        +	return nil, false

      
        
        148
        +}

      
        
        149
        +

      
        
        150
        +func waitFor(t *testing.T, what string, cond func() bool) {

      
        
        151
        +	t.Helper()

      
        
        152
        +	deadline := time.Now().Add(5 * time.Second)

      
        
        153
        +	for time.Now().Before(deadline) {

      
        
        154
        +		if cond() {

      
        
        155
        +			return

      
        
        156
        +		}

      
        
        157
        +		time.Sleep(10 * time.Millisecond)

      
        
        158
        +	}

      
        
        159
        +	t.Fatalf("timed out waiting for %s", what)

      
        
        160
        +}

      
M internal/lsp/textdocument_completion.go
···
        30
        30
         	if detectedCtx == cmplNone {

      
        31
        31
         		return &protocol.CompletionList{}, nil

      
        32
        32
         	}

      
        33
        
        -	a := s.analysis()

      
        34
        
        -	if a == nil {

      
        
        33
        +	an := s.analysisFor(params.TextDocument.URI)

      
        
        34
        +	if an == nil {

      
        35
        35
         		return &protocol.CompletionList{}, nil

      
        36
        36
         	}

      
        37
        37
         	return &protocol.CompletionList{

      
        38
        38
         		IsIncomplete: true,

      
        39
        
        -		Items:        cmplItems(a, detectedCtx, state.text, state.lineIdx, start, cursor),

      
        
        39
        +		Items:        cmplItems(an, detectedCtx, state.text, state.lineIdx, start, cursor),

      
        40
        40
         	}, nil

      
        41
        41
         }

      
        42
        42
         

      
M internal/lsp/textdocument_completion_test.go
···
        
        1
        +// TODO:

      
        1
        2
         package lsp

      
        2
        3
         

      
        3
        4
         import (

      ···
        9
        10
         	"go.lsp.dev/protocol"

      
        10
        11
         	"go.lsp.dev/uri"

      
        11
        12
         

      
        12
        
        -	"olexsmir.xyz/clerk/internal/analyzer"

      
        13
        13
         	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        14
        14
         	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        15
        15
         	"olexsmir.xyz/clerk/journal"

      ···
        83
        83
         	if err != nil {

      
        84
        84
         		b.Fatal(err)

      
        85
        85
         	}

      
        86
        
        -	a := analyzer.Build(rj)

      
        87
        86
         	content := string(rj.Occurrences[0].Src)

      
        88
        87
         

      
        89
        88
         	srv := NewServer("test")

      
        90
        89
         	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")

      
        91
        
        -	srv.server.current = a

      
        
        90
        +	srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache

      
        92
        91
         

      
        93
        92
         	for tname, tt := range map[string]int{

      
        94
        93
         		"1k txns, account":     strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2,

      
M internal/lsp/textdocument_definition.go
···
        20
        20
         		return nil, nil

      
        21
        21
         	}

      
        22
        22
         

      
        23
        
        -	an := s.analysis()

      
        
        23
        +	an := s.analysisFor(params.TextDocument.URI)

      
        24
        24
         	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))

      
        25
        25
         	return findDefinitionUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor), nil

      
        26
        26
         }

      
M internal/lsp/textdocument_definition_test.go
···
        9
        9
         	"go.lsp.dev/protocol"

      
        10
        10
         	"go.lsp.dev/uri"

      
        11
        11
         

      
        12
        
        -	"olexsmir.xyz/clerk/internal/analyzer"

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

      
        14
        13
         	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        15
        14
         	"olexsmir.xyz/clerk/journal"

      ···
        71
        70
         	if err != nil {

      
        72
        71
         		b.Fatal(err)

      
        73
        72
         	}

      
        74
        
        -	a := analyzer.Build(rj)

      
        75
        73
         	content := string(rj.Occurrences[0].Src)

      
        76
        74
         

      
        77
        75
         	srv := NewServer("test")

      
        78
        76
         	u := uri.File(abs)

      
        79
        77
         	srv.server.openDoc(u, content, 1, "journal")

      
        80
        
        -	srv.server.current = a

      
        
        78
        +	srv.server.analysisFor(u) // warm the per-doc cache

      
        81
        79
         

      
        82
        80
         	for tname, tt := range map[string]int{

      
        83
        81
         		"1k txns, account":   strings.Index(content, "\n  1:2:3 ") + len("\n  ") + 2,

      
M internal/lsp/textdocument_hover.go
···
        18
        18
         		return nil, nil

      
        19
        19
         	}

      
        20
        20
         

      
        21
        
        -	an := s.analysis()

      
        
        21
        +	an := s.analysisFor(params.TextDocument.URI)

      
        22
        22
         	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))

      
        23
        23
         	el := hoverAt(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        24
        24
         	if el == nil {

      
M internal/lsp/textdocument_hover_test.go
···
        13
        13
         	"olexsmir.xyz/clerk/internal/analyzer"

      
        14
        14
         	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        15
        15
         	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        16
        
        -	"olexsmir.xyz/clerk/journal"

      
        17
        16
         	"olexsmir.xyz/clerk/journal/ast"

      
        18
        17
         	"olexsmir.xyz/clerk/journal/token"

      
        19
        18
         )

      ···
        109
        108
         func BenchmarkHover(b *testing.B) {

      
        110
        109
         	content := openJouranl(b, "../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        111
        110
         

      
        112
        
        -	// Path must match the open doc: hoverAt resolves the document by path.

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

      
        114
        
        -

      
        115
        111
         	srv := NewServer("test")

      
        116
        112
         	srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal")

      
        117
        
        -	srv.server.current = an

      
        
        113
        +	srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache

      
        118
        114
         

      
        119
        115
         	for tname, tt := range map[string]int{

      
        120
        116
         		"date":              strings.Index(content, "2000-01-01 transaction 1") + 3,

      
M internal/lsp/textdocument_rename.go
···
        21
        21
         		return nil, nil

      
        22
        22
         	}

      
        23
        23
         

      
        24
        
        -	an := s.analysis()

      
        
        24
        +	an := s.analysisFor(params.TextDocument.URI)

      
        25
        25
         	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))

      
        26
        26
         	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        27
        27
         	if ref == nil {

      ···
        40
        40
         		return nil, nil

      
        41
        41
         	}

      
        42
        42
         

      
        43
        
        -	an := s.analysis()

      
        
        43
        +	an := s.analysisFor(params.TextDocument.URI)

      
        44
        44
         	cursor := state.lineIdx.Offset(int(params.Position.Line), int(params.Position.Character))

      
        45
        45
         	ref := findSymbolUnderCursor(an, params.TextDocument.URI.Path(), state.text, cursor)

      
        46
        46
         	if ref == nil {

      
M internal/lsp/textdocument_rename_test.go
···
        11
        11
         	"go.lsp.dev/protocol"

      
        12
        12
         	"go.lsp.dev/uri"

      
        13
        13
         

      
        14
        
        -	"olexsmir.xyz/clerk/internal/analyzer"

      
        15
        14
         	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        16
        15
         	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        17
        16
         	"olexsmir.xyz/clerk/journal"

      ···
        137
        136
         	if err != nil {

      
        138
        137
         		b.Fatal(err)

      
        139
        138
         	}

      
        140
        
        -	a := analyzer.Build(rj)

      
        141
        139
         	content := string(rj.Occurrences[0].Src)

      
        142
        140
         

      
        143
        141
         	srv := NewServer("test")

      
        144
        142
         	u := uri.File(abs)

      
        145
        143
         	srv.server.openDoc(u, content, 1, "journal")

      
        146
        
        -	srv.server.current = a

      
        
        144
        +	srv.server.analysisFor(u) // warm the per-doc cache

      
        147
        145
         

      
        148
        146
         	for tname, tt := range map[string]struct {

      
        149
        147
         		newName string

      
M internal/lsp/textdocument_sync.go
···
        6
        6
         	"go.lsp.dev/protocol"

      
        7
        7
         	"go.lsp.dev/uri"

      
        8
        8
         

      
        
        9
        +	"olexsmir.xyz/clerk/internal/analyzer"

      
        9
        10
         	"olexsmir.xyz/clerk/internal/lsp/lsputil"

      
        
        11
        +	"olexsmir.xyz/clerk/journal"

      
        10
        12
         	"olexsmir.xyz/clerk/journal/ast"

      
        11
        13
         	"olexsmir.xyz/clerk/journal/lexer"

      
        12
        14
         	"olexsmir.xyz/clerk/journal/parser"

      ···
        25
        27
         }

      
        26
        28
         

      
        27
        29
         func (s *server) DidClose(ctx context.Context, params *protocol.DidCloseTextDocumentParams) error {

      
        28
        
        -	s.closeDoc(params.TextDocument.URI)

      
        
        30
        +	u := params.TextDocument.URI

      
        
        31
        +	s.closeDoc(u)

      
        
        32
        +

      
        
        33
        +	// clear closed doc's diagnostics; dependents rebuild from disk since the buffer text is gone.

      
        
        34
        +	s.markDependentsDirty(u)

      
        
        35
        +	if err := s.client.PublishDiagnostics(ctx, &protocol.PublishDiagnosticsParams{

      
        
        36
        +		URI:         u,

      
        
        37
        +		Diagnostics: []protocol.Diagnostic{},

      
        
        38
        +	}); err != nil {

      
        
        39
        +		s.log.Warn("clear diagnostics failed", "uri", u, "err", err)

      
        
        40
        +	}

      
        
        41
        +

      
        29
        42
         	s.scheduleDiagnostics(ctx)

      
        30
        43
         	return nil

      
        31
        44
         }

      ···
        42
        55
         	languageID protocol.LanguageKind

      
        43
        56
         	semTokens  []semanticToken    // cached semantic tokens

      
        44
        57
         	lineIdx    *lsputil.LineIndex // cached line index for the text

      
        
        58
        +	analysis   *analyzer.Analysis // cached analysis, nil until first build

      
        
        59
        +	paths      map[string]bool    // canonical paths of every file in the cached analysis

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

      
        45
        61
         }

      
        46
        62
         

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

      ···
        51
        67
         		version:    version,

      
        52
        68
         		languageID: langID,

      
        53
        69
         		lineIdx:    lsputil.NewLineIndex(text),

      
        
        70
        +		dirty:      true,

      
        54
        71
         	}

      
        55
        72
         	s.mu.Unlock()

      
        56
        73
         }

      
        57
        74
         

      
        58
        75
         func (s *server) updateDoc(u uri.URI, version int32, changes []protocol.TextDocumentContentChangeEvent) {

      
        59
        76
         	s.mu.Lock()

      
        60
        
        -	defer s.mu.Unlock()

      
        61
        77
         	state, ok := s.openDocs[u]

      
        62
        78
         	if !ok {

      
        
        79
        +		s.mu.Unlock()

      
        63
        80
         		return

      
        64
        81
         	}

      
        65
        82
         	state.version = version

      ···
        73
        90
         			_ = ev // TODO: incremental edit support

      
        74
        91
         		}

      
        75
        92
         	}

      
        
        93
        +	state.analysis = nil

      
        
        94
        +	state.dirty = true

      
        76
        95
         	s.openDocs[u] = state

      
        
        96
        +	s.mu.Unlock()

      
        
        97
        +

      
        
        98
        +	s.markDependentsDirty(u)

      
        
        99
        +}

      
        
        100
        +

      
        
        101
        +// markDependentsDirty dirties every open doc whose cached analysis includes u,

      
        
        102
        +// e.g. after u is edited or closed.

      
        
        103
        +func (s *server) markDependentsDirty(u uri.URI) {

      
        
        104
        +	canon := journal.CanonicalPath(u.Path())

      
        
        105
        +	s.mu.Lock()

      
        
        106
        +	for du, dstate := range s.openDocs {

      
        
        107
        +		if dstate.paths[canon] {

      
        
        108
        +			dstate.dirty = true

      
        
        109
        +			s.openDocs[du] = dstate

      
        
        110
        +		}

      
        
        111
        +	}

      
        
        112
        +	s.mu.Unlock()

      
        77
        113
         }

      
        78
        114
         

      
        79
        115
         func (s *server) getDocState(u uri.URI) (docState, bool) {

      
M journal/loader.go
···
        19
        19
         	Path       string

      
        20
        20
         	Src        []byte

      
        21
        21
         	Ast        *ast.Journal

      
        22
        
        -	Includes   []*ParsedFile

      
        23
        22
         	FileErrors []*ast.FileError

      
        24
        23
         	Errors     []*ast.ParseError

      
        25
        24
         }

      ···
        35
        34
         // Resolve/ResolveBytes returns a fresh journal; same source path may appear

      
        36
        35
         // as multiple occurrences with different parser contexts.

      
        37
        36
         type ResolvedJournal struct {

      
        38
        
        -	Primary     *ParsedFile    // first occurrence (root file)

      
        39
        37
         	Occurrences []*ParsedFile  // all occurrences in depth-first order

      
        40
        38
         	Items       []ResolvedItem // flat stream, occur-order

      
        41
        39
         	ByPath      map[string][]*ParsedFile

      ···
        50
        48
         	return all

      
        51
        49
         }

      
        52
        50
         

      
        
        51
        +// parseCacheMax bounds the number of parsed files retained by the loader.

      
        
        52
        +const parseCacheMax = 64

      
        
        53
        +

      
        
        54
        +type parseKey struct {

      
        
        55
        +	canon, content string

      
        
        56
        +	defaultYear    int

      
        
        57
        +}

      
        
        58
        +

      
        
        59
        +type parseEntry struct {

      
        
        60
        +	src []byte

      
        
        61
        +	ast *ast.Journal

      
        
        62
        +}

      
        
        63
        +

      
        53
        64
         // Loader include-aware journal parsing caching.

      
        54
        65
         type Loader struct {

      
        55
        66
         	mu           sync.RWMutex

      
        56
        67
         	contentCache map[string][]byte // canonical path: normalised content

      
        
        68
        +	parseCache   map[parseKey]parseEntry

      
        
        69
        +

      
        
        70
        +	// ContentProvider, when set, is consulted before any disk read. It returns

      
        
        71
        +	// the file's authoritative content and ok=true, or ok=false to fall back to

      
        
        72
        +	// disk. The zero value (nil) keeps the loader disk-only.

      
        
        73
        +	ContentProvider func(path string) ([]byte, bool)

      
        57
        74
         }

      
        58
        75
         

      
        59
        76
         func NewLoader() *Loader {

      
        60
        
        -	return &Loader{contentCache: make(map[string][]byte)}

      
        
        77
        +	return &Loader{

      
        
        78
        +		contentCache: make(map[string][]byte),

      
        
        79
        +		parseCache:   make(map[parseKey]parseEntry),

      
        
        80
        +	}

      
        61
        81
         }

      
        62
        82
         

      
        63
        83
         // Resolve performs a fresh include-aware parse of fpath, returning a flat

      ···
        77
        97
         		ByPath: make(map[string][]*ParsedFile),

      
        78
        98
         	}

      
        79
        99
         	l.resolveOccurrence(rj, nil, fpath, src, 0, nil)

      
        80
        
        -	if len(rj.Occurrences) > 0 {

      
        81
        
        -		rj.Primary = rj.Occurrences[0]

      
        82
        
        -	}

      
        83
        100
         	return rj

      
        84
        101
         }

      
        85
        102
         

      ···
        106
        123
         }

      
        107
        124
         

      
        108
        125
         func (l *Loader) remapFilePaths(rj *ResolvedJournal, oldRoot string) {

      
        109
        
        -	// remap paths; keep Include field consistent for walkers

      
        110
        126
         	for _, pf := range rj.Occurrences {

      
        111
        127
         		if rel, err := filepath.Rel(oldRoot, pf.Path); err == nil {

      
        112
        128
         			pf.Path = filepath.ToSlash(rel)

      ···
        127
        143
         

      
        128
        144
         // InvalidateFile removes a file from the content cache

      
        129
        145
         func (l *Loader) InvalidateFile(fpath string) {

      
        130
        
        -	canon := canonicalPath(fpath)

      
        
        146
        +	canon := CanonicalPath(fpath)

      
        131
        147
         	l.mu.Lock()

      
        132
        148
         	delete(l.contentCache, canon)

      
        133
        149
         	l.mu.Unlock()

      
        134
        150
         }

      
        135
        151
         

      
        136
        
        -// ClearCache empties the content cache

      
        137
        
        -func (l *Loader) ClearCache() {

      
        138
        
        -	l.mu.Lock()

      
        139
        
        -	l.contentCache = make(map[string][]byte)

      
        140
        
        -	l.mu.Unlock()

      
        141
        
        -}

      
        
        152
        +// readContent reads a file, preferring the content provider, then the disk content cache.

      
        
        153
        +func (l *Loader) readContent(fpath string) ([]byte, error) {

      
        
        154
        +	if l.ContentProvider != nil {

      
        
        155
        +		if content, ok := l.ContentProvider(fpath); ok {

      
        
        156
        +			return normaliseNewlines(content), nil

      
        
        157
        +		}

      
        
        158
        +	}

      
        142
        159
         

      
        143
        
        -// readContent reads a file via content cache; normalises line endings and caches.

      
        144
        
        -func (l *Loader) readContent(fpath string) ([]byte, error) {

      
        145
        
        -	canon := canonicalPath(fpath)

      
        
        160
        +	canon := CanonicalPath(fpath)

      
        146
        161
         

      
        147
        162
         	l.mu.RLock()

      
        148
        163
         	content, ok := l.contentCache[canon]

      ···
        156
        171
         		return nil, err

      
        157
        172
         	}

      
        158
        173
         

      
        159
        
        -	// normalise line endings for consistent caching and parsing

      
        160
        
        -	content = bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))

      
        161
        
        -	content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))

      
        162
        
        -

      
        
        174
        +	content = normaliseNewlines(raw)

      
        163
        175
         	l.mu.Lock()

      
        164
        176
         	l.contentCache[canon] = content

      
        165
        177
         	l.mu.Unlock()

      
        166
        178
         	return content, nil

      
        167
        179
         }

      
        168
        180
         

      
        
        181
        +func normaliseNewlines(raw []byte) []byte {

      
        
        182
        +	content := bytes.ReplaceAll(raw, []byte("\r\n"), []byte("\n"))

      
        
        183
        +	return bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))

      
        
        184
        +}

      
        
        185
        +

      
        169
        186
         // resolveOccurrence recursively parses one occurrence and its includes

      
        170
        187
         func (l *Loader) resolveOccurrence(rj *ResolvedJournal, parent *ParsedFile, fpath string, src []byte, defaultYear int, stack []string) {

      
        171
        188
         	// cycle detection uses canonical paths to catch cycles through symlinks.

      
        172
        
        -	canon := canonicalPath(fpath)

      
        
        189
        +	canon := CanonicalPath(fpath)

      
        173
        190
         	if slices.Contains(stack, canon) {

      
        174
        191
         		if parent != nil {

      
        175
        192
         			parent.FileErrors = append(parent.FileErrors, &ast.FileError{

      ···
        180
        197
         		return

      
        181
        198
         	}

      
        182
        199
         

      
        183
        
        -	lex := lexer.New(fpath, src)

      
        184
        
        -	par := parser.NewWithYear(lex, defaultYear)

      
        185
        
        -	j := par.ParseJournal()

      
        
        200
        +	key := parseKey{canon: canon, content: string(src), defaultYear: defaultYear}

      
        
        201
        +	entry, ok := l.parseLookup(key)

      
        
        202
        +	if !ok {

      
        
        203
        +		lex := lexer.New(fpath, src)

      
        
        204
        +		par := parser.NewWithYear(lex, defaultYear)

      
        
        205
        +		j := par.ParseJournal()

      
        
        206
        +		entry = parseEntry{src: src, ast: j}

      
        
        207
        +		l.parseStore(key, entry)

      
        
        208
        +	}

      
        186
        209
         

      
        187
        210
         	pf := &ParsedFile{

      
        188
        
        -		Path:     fpath,

      
        189
        
        -		Src:      src,

      
        190
        
        -		Ast:      j,

      
        191
        
        -		Includes: []*ParsedFile{},

      
        192
        
        -		Errors:   j.Errors,

      
        
        211
        +		Path:   fpath,

      
        
        212
        +		Src:    entry.src,

      
        
        213
        +		Ast:    entry.ast,

      
        
        214
        +		Errors: entry.ast.Errors,

      
        193
        215
         	}

      
        194
        216
         	rj.Occurrences = append(rj.Occurrences, pf)

      
        195
        217
         	rj.ByPath[fpath] = append(rj.ByPath[fpath], pf)

      
        196
        218
         

      
        197
        
        -	if parent != nil {

      
        198
        
        -		parent.Includes = append(parent.Includes, pf)

      
        199
        
        -	}

      
        200
        
        -

      
        201
        219
         	currentYear := defaultYear

      
        202
        
        -	for i, entry := range j.Entries {

      
        
        220
        +	for i, entry := range entry.ast.Entries {

      
        203
        221
         		switch e := entry.(type) {

      
        204
        222
         		case *ast.BlankLine:

      
        205
        223
         			continue

      ···
        270
        288
         	return target, nil

      
        271
        289
         }

      
        272
        290
         

      
        273
        
        -func canonicalPath(path string) string {

      
        
        291
        +// CanonicalPath resolvea path to it's canonical form: absolute, symlinks evaluated, cleaned.

      
        
        292
        +func CanonicalPath(path string) string {

      
        274
        293
         	abs, err := filepath.Abs(path)

      
        275
        294
         	if err != nil {

      
        276
        295
         		return filepath.Clean(path)

      ···
        281
        300
         	}

      
        282
        301
         	return filepath.Clean(canonical)

      
        283
        302
         }

      
        
        303
        +

      
        
        304
        +func (l *Loader) parseLookup(key parseKey) (parseEntry, bool) {

      
        
        305
        +	l.mu.RLock()

      
        
        306
        +	entry, ok := l.parseCache[key]

      
        
        307
        +	l.mu.RUnlock()

      
        
        308
        +	return entry, ok

      
        
        309
        +}

      
        
        310
        +

      
        
        311
        +func (l *Loader) parseStore(key parseKey, entry parseEntry) {

      
        
        312
        +	l.mu.Lock()

      
        
        313
        +	l.parseCache[key] = entry

      
        
        314
        +	if len(l.parseCache) > parseCacheMax {

      
        
        315
        +		for k := range l.parseCache {

      
        
        316
        +			delete(l.parseCache, k)

      
        
        317
        +			break

      
        
        318
        +		}

      
        
        319
        +	}

      
        
        320
        +	l.mu.Unlock()

      
        
        321
        +}

      
M journal/loader_test.go
···
        4
        4
         	"fmt"

      
        5
        5
         	"os"

      
        6
        6
         	"path/filepath"

      
        
        7
        +	"strings"

      
        7
        8
         	"testing"

      
        8
        9
         

      
        
        10
        +	"olexsmir.xyz/clerk/internal/testutil"

      
        
        11
        +	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        9
        12
         	"olexsmir.xyz/clerk/internal/testutil/txtar"

      
        10
        13
         	"olexsmir.xyz/clerk/journal/ast"

      
        11
        14
         )

      
        12
        15
         

      
        13
        
        -func TestLoader_Resolve_basic(t *testing.T) {

      
        14
        
        -	rj := resolveTxtar(t, "root.journal", `

      
        15
        
        --- root.journal --

      
        16
        
        -2024/01/01 t

      
        17
        
        -  a  $1

      
        18
        
        -`)

      
        19
        
        -	if len(rj.Occurrences) != 1 || len(rj.Items) != 1 || rj.Items[0].IsInclude {

      
        20
        
        -		t.Fatal("basic: expected 1 file, 1 non-include item")

      
        21
        
        -	}

      
        22
        
        -}

      
        23
        
        -

      
        24
        
        -func TestLoader_Resolve_withInclude(t *testing.T) {

      
        25
        
        -	rj := resolveTxtar(t, "parent.journal", `

      
        26
        
        --- parent.journal --

      
        27
        
        -include child.journal

      
        28
        
        -2024/01/01 t

      
        29
        
        -  a  $1

      
        30
        
        -

      
        31
        
        --- child.journal --

      
        32
        
        -account expenses:food

      
        33
        
        -2024/06/15 lunch

      
        34
        
        -  expenses:food  $5

      
        35
        
        -  assets:cash

      
        36
        
        -`)

      
        37
        
        -	// 2 files: parent (include + tx), child (directive + tx) = 4 items

      
        38
        
        -	if len(rj.Items) != 4 {

      
        39
        
        -		t.Fatalf("expected 4 items, got %d", len(rj.Items))

      
        40
        
        -	}

      
        41
        
        -	if !rj.Items[0].IsInclude {

      
        42
        
        -		t.Fatal("items[0] should be include marker")

      
        43
        
        -	}

      
        44
        
        -	if len(rj.Occurrences) != 2 {

      
        45
        
        -		t.Fatal("expected 2 occurrences")

      
        46
        
        -	}

      
        47
        
        -}

      
        48
        
        -

      
        49
        
        -func TestLoader_Resolve_yearPropagation(t *testing.T) {

      
        50
        
        -	rj := resolveTxtar(t, "parent.journal", `

      
        51
        
        --- parent.journal --

      
        52
        
        -year 2025

      
        53
        
        -include child.journal

      
        54
        
        -12-01 dinner

      
        55
        
        -  expenses:food  $10

      
        56
        
        -  assets:cash

      
        57
        
        -

      
        58
        
        --- child.journal --

      
        59
        
        -06-15 lunch

      
        60
        
        -  expenses:food  $5

      
        61
        
        -  assets:cash

      
        62
        
        -`)

      
        63
        
        -	dates := make([]string, 0, 2)

      
        64
        
        -	for _, item := range rj.Items {

      
        65
        
        -		if item.IsInclude {

      
        66
        
        -			continue

      
        67
        
        -		}

      
        68
        
        -		tx, ok := item.Occurrence.Ast.Entries[item.EntryIndex].(*ast.Transaction)

      
        69
        
        -		if !ok {

      
        70
        
        -			continue

      
        71
        
        -		}

      
        72
        
        -		dates = append(dates, fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day))

      
        73
        
        -	}

      
        74
        
        -	if len(dates) != 2 || dates[0] != "2025-06-15" || dates[1] != "2025-12-01" {

      
        75
        
        -		t.Fatalf("expected [2025-06-15 2025-12-01], got %v", dates)

      
        
        16
        +func TestLoader_Resolve(t *testing.T) {

      
        
        17
        +	for _, tname := range []string{"basic", "with-include", "year-propagation", "year-context", "repeated-include", "parse-errors"} {

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

      
        
        19
        +			a := golden.Read(t, tname)

      
        
        20
        +			fsys, err := a.FS()

      
        
        21
        +			if err != nil {

      
        
        22
        +				t.Fatal(err)

      
        
        23
        +			}

      
        
        24
        +			rj, err := NewLoader().ResolveFS(fsys, "in.journal")

      
        
        25
        +			if err != nil {

      
        
        26
        +				t.Fatalf("resolving in.journal: %v", err)

      
        
        27
        +			}

      
        
        28
        +			golden.Assert(t, a, dumpResolved(rj))

      
        
        29
        +		})

      
        76
        30
         	}

      
        77
        31
         }

      
        78
        32
         

      ···
        89
        43
         	}

      
        90
        44
         }

      
        91
        45
         

      
        92
        
        -func TestLoader_Resolve_repeatedInclude(t *testing.T) {

      
        93
        
        -	rj := resolveTxtar(t, "parent.journal", `

      
        94
        
        --- parent.journal --

      
        95
        
        -include shared.journal

      
        96
        
        -include shared.journal

      
        97
        
        -

      
        98
        
        --- shared.journal --

      
        99
        
        -account expenses:shared

      
        100
        
        -`)

      
        101
        
        -	if len(rj.Occurrences) != 3 {

      
        102
        
        -		t.Fatalf("expected 3 occurrences, got %d", len(rj.Occurrences))

      
        103
        
        -	}

      
        104
        
        -	if len(rj.ByPath["shared.journal"]) != 2 {

      
        105
        
        -		t.Fatalf("expected 2 ByPath entries for shared.journal, got %d",

      
        106
        
        -			len(rj.ByPath["shared.journal"]))

      
        
        46
        +func dumpResolved(rj *ResolvedJournal) string {

      
        
        47
        +	var b strings.Builder

      
        
        48
        +	for _, pf := range rj.Occurrences {

      
        
        49
        +		fmt.Fprintf(&b, "== %s ==\n", pf.Path)

      
        
        50
        +		for i, e := range pf.Ast.Entries {

      
        
        51
        +			if s := entrySummary(e); s != "" {

      
        
        52
        +				fmt.Fprintf(&b, "  %d %s\n", i+1, s)

      
        
        53
        +			}

      
        
        54
        +		}

      
        
        55
        +		for _, fe := range pf.FileErrors {

      
        
        56
        +			fmt.Fprintf(&b, "  error: %s\n", fe.Message)

      
        
        57
        +		}

      
        
        58
        +		for _, pe := range pf.Errors {

      
        
        59
        +			fmt.Fprintf(&b, "  parse-error: %s\n", pe.Message)

      
        
        60
        +		}

      
        107
        61
         	}

      
        
        62
        +	return b.String()

      
        108
        63
         }

      
        109
        64
         

      
        110
        
        -func TestLoader_ResolveBytes_parseErrors(t *testing.T) {

      
        111
        
        -	rj := NewLoader().ResolveBytes("bad.journal", []byte("@@@ garbage\n"))

      
        112
        
        -	if len(rj.Occurrences[0].Errors) == 0 {

      
        113
        
        -		t.Fatal("expected parse errors for garbage input")

      
        
        65
        +func entrySummary(e ast.Entry) string {

      
        
        66
        +	switch e := e.(type) {

      
        
        67
        +	case *ast.BlankLine:

      
        
        68
        +		return ""

      
        
        69
        +	case *ast.IncludeDirective:

      
        
        70
        +		return "include " + e.Path

      
        
        71
        +	case *ast.YearDirective:

      
        
        72
        +		return fmt.Sprintf("year %d", e.Year)

      
        
        73
        +	case *ast.AccountDirective:

      
        
        74
        +		return "account " + e.Account.String()

      
        
        75
        +	case *ast.Transaction:

      
        
        76
        +		s := fmt.Sprintf("%04d-%02d-%02d", e.Date.Year, e.Date.Month, e.Date.Day)

      
        
        77
        +		if e.Payee != nil {

      
        
        78
        +			s += " " + e.Payee.Name

      
        
        79
        +		}

      
        
        80
        +		return s

      
        
        81
        +	default:

      
        
        82
        +		return fmt.Sprintf("<%T>", e)

      
        114
        83
         	}

      
        115
        84
         }

      
        116
        85
         

      ···
        151
        120
         	}

      
        152
        121
         }

      
        153
        122
         

      
        154
        
        -func TestLoader_ClearCache(t *testing.T) {

      
        155
        
        -	l := NewLoader()

      
        156
        
        -	if len(l.contentCache) != 0 {

      
        157
        
        -		t.Fatal("expected empty cache")

      
        158
        
        -	}

      
        159
        
        -	l.contentCache["x"] = []byte("y")

      
        160
        
        -	l.ClearCache()

      
        161
        
        -	if len(l.contentCache) != 0 {

      
        162
        
        -		t.Fatal("expected empty cache after clear")

      
        163
        
        -	}

      
        164
        
        -}

      
        165
        
        -

      
        166
        123
         func TestResolveIncludePath(t *testing.T) {

      
        167
        124
         	tests := []struct {

      
        168
        125
         		parent, pattern string

      ···
        219
        176
         	tx := rj.Occurrences[0].Ast.Entries[0].(*ast.Transaction)

      
        220
        177
         	return fmt.Sprintf("%d-%02d-%02d", tx.Date.Year, tx.Date.Month, tx.Date.Day)

      
        221
        178
         }

      
        
        179
        +

      
        
        180
        +// writeFiles writes the given files into dir.

      
        
        181
        +func writeFiles(t *testing.T, dir string, files map[string]string) {

      
        
        182
        +	t.Helper()

      
        
        183
        +	for name, content := range files {

      
        
        184
        +		testutil.WriteFile(t, filepath.Join(dir, name), []byte(content))

      
        
        185
        +	}

      
        
        186
        +}

      
        
        187
        +

      
        
        188
        +// newLoaderDir writes files into a temp dir and returns a loader over it.

      
        
        189
        +func newLoaderDir(t *testing.T, files map[string]string) (*Loader, string) {

      
        
        190
        +	t.Helper()

      
        
        191
        +	dir := t.TempDir()

      
        
        192
        +	writeFiles(t, dir, files)

      
        
        193
        +	return NewLoader(), dir

      
        
        194
        +}

      
        
        195
        +

      
        
        196
        +// mustOccurrence returns the occurrence of path in the resolve.

      
        
        197
        +func mustOccurrence(t *testing.T, rj *ResolvedJournal, path string) *ParsedFile {

      
        
        198
        +	t.Helper()

      
        
        199
        +	got := rj.ByPath[path]

      
        
        200
        +	if len(got) == 0 {

      
        
        201
        +		t.Fatalf("file not resolved: %s", path)

      
        
        202
        +	}

      
        
        203
        +	return got[0]

      
        
        204
        +}

      
        
        205
        +

      
        
        206
        +// TestLoader_ParseCache_SharedPointer: two roots including the same file get

      
        
        207
        +// the same *ast.Journal for it (one parse, shared).

      
        
        208
        +func TestLoader_ParseCache_SharedPointer(t *testing.T) {

      
        
        209
        +	l, dir := newLoaderDir(t, map[string]string{

      
        
        210
        +		"a.journal": "include c.journal\n",

      
        
        211
        +		"b.journal": "include c.journal\n",

      
        
        212
        +		"c.journal": "account expenses:food\n",

      
        
        213
        +	})

      
        
        214
        +	c := filepath.Join(dir, "c.journal")

      
        
        215
        +

      
        
        216
        +	ca := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "a.journal")), c)

      
        
        217
        +	cb := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "b.journal")), c)

      
        
        218
        +	if ca.Ast != cb.Ast {

      
        
        219
        +		t.Error("c.journal parsed twice: Ast pointers differ")

      
        
        220
        +	}

      
        
        221
        +	if ca == cb {

      
        
        222
        +		t.Error("ParsedFile wrapper must be per-resolve")

      
        
        223
        +	}

      
        
        224
        +}

      
        
        225
        +

      
        
        226
        +// TestLoader_ParseCache_ContentChange: new content forces a new parse.

      
        
        227
        +func TestLoader_ParseCache_ContentChange(t *testing.T) {

      
        
        228
        +	l, dir := newLoaderDir(t, map[string]string{

      
        
        229
        +		"a.journal": "include c.journal\n",

      
        
        230
        +		"c.journal": "account expenses:food\n",

      
        
        231
        +	})

      
        
        232
        +	a, c := filepath.Join(dir, "a.journal"), filepath.Join(dir, "c.journal")

      
        
        233
        +

      
        
        234
        +	first := mustOccurrence(t, mustResolve(t, l, a), c).Ast

      
        
        235
        +	writeFiles(t, dir, map[string]string{"c.journal": "account expenses:travel\n"})

      
        
        236
        +	l.InvalidateFile(c) // the content cache would serve stale bytes

      
        
        237
        +	second := mustOccurrence(t, mustResolve(t, l, a), c).Ast

      
        
        238
        +	if first == second {

      
        
        239
        +		t.Error("edited file served the old parse")

      
        
        240
        +	}

      
        
        241
        +}

      
        
        242
        +

      
        
        243
        +// TestLoader_ContentProvider: open-buffer text wins over disk for includes.

      
        
        244
        +func TestLoader_ContentProvider(t *testing.T) {

      
        
        245
        +	l, dir := newLoaderDir(t, map[string]string{

      
        
        246
        +		"a.journal": "include c.journal\n",

      
        
        247
        +		"c.journal": "account expenses:old\n",

      
        
        248
        +	})

      
        
        249
        +	l.ContentProvider = func(path string) ([]byte, bool) {

      
        
        250
        +		if filepath.Base(path) == "c.journal" {

      
        
        251
        +			return []byte("account expenses:new\n"), true

      
        
        252
        +		}

      
        
        253
        +		return nil, false

      
        
        254
        +	}

      
        
        255
        +

      
        
        256
        +	pf := mustOccurrence(t, mustResolve(t, l, filepath.Join(dir, "a.journal")), filepath.Join(dir, "c.journal"))

      
        
        257
        +	ad := pf.Ast.Entries[0].(*ast.AccountDirective)

      
        
        258
        +	if ad.Account.String() != "expenses:new" {

      
        
        259
        +		t.Errorf("included content = %q, want expenses:new (buffer wins over disk)", ad.Account.String())

      
        
        260
        +	}

      
        
        261
        +}

      
A journal/testdata/basic.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +2024/01/01 t

      
        
        3
        +  a  $1

      
        
        4
        +

      
        
        5
        +-- expect --

      
        
        6
        +== in.journal ==

      
        
        7
        +  1 2024-01-01 t

      
A journal/testdata/parse-errors.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +@@@ garbage

      
        
        3
        +

      
        
        4
        +-- expect --

      
        
        5
        +== in.journal ==

      
        
        6
        +  parse-error: unexpected token AT

      
A journal/testdata/repeated-include.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include shared.journal

      
        
        3
        +include shared.journal

      
        
        4
        +

      
        
        5
        +-- shared.journal --

      
        
        6
        +account expenses:shared

      
        
        7
        +

      
        
        8
        +-- expect --

      
        
        9
        +== in.journal ==

      
        
        10
        +  1 include shared.journal

      
        
        11
        +  2 include shared.journal

      
        
        12
        +== shared.journal ==

      
        
        13
        +  1 account expenses:shared

      
        
        14
        +== shared.journal ==

      
        
        15
        +  1 account expenses:shared

      
A journal/testdata/with-include.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include child.journal

      
        
        3
        +2024/01/01 t

      
        
        4
        +  a  $1

      
        
        5
        +

      
        
        6
        +-- child.journal --

      
        
        7
        +account expenses:food

      
        
        8
        +2024/06/15 lunch

      
        
        9
        +  expenses:food  $5

      
        
        10
        +  assets:cash

      
        
        11
        +

      
        
        12
        +-- expect --

      
        
        13
        +== in.journal ==

      
        
        14
        +  1 include child.journal

      
        
        15
        +  2 2024-01-01 t

      
        
        16
        +== child.journal ==

      
        
        17
        +  1 account expenses:food

      
        
        18
        +  2 2024-06-15 lunch

      
A journal/testdata/year-context.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include a.journal

      
        
        3
        +include b.journal

      
        
        4
        +

      
        
        5
        +-- a.journal --

      
        
        6
        +year 2025

      
        
        7
        +include c.journal

      
        
        8
        +

      
        
        9
        +-- b.journal --

      
        
        10
        +year 2030

      
        
        11
        +include c.journal

      
        
        12
        +

      
        
        13
        +-- c.journal --

      
        
        14
        +06-15 lunch

      
        
        15
        +  expenses:food  $5

      
        
        16
        +  assets:cash

      
        
        17
        +

      
        
        18
        +-- expect --

      
        
        19
        +== in.journal ==

      
        
        20
        +  1 include a.journal

      
        
        21
        +  2 include b.journal

      
        
        22
        +== a.journal ==

      
        
        23
        +  1 year 2025

      
        
        24
        +  2 include c.journal

      
        
        25
        +== c.journal ==

      
        
        26
        +  1 2025-06-15 lunch

      
        
        27
        +== b.journal ==

      
        
        28
        +  1 year 2030

      
        
        29
        +  2 include c.journal

      
        
        30
        +== c.journal ==

      
        
        31
        +  1 2030-06-15 lunch

      
A journal/testdata/year-propagation.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +year 2025

      
        
        3
        +include child.journal

      
        
        4
        +12-01 dinner

      
        
        5
        +  expenses:food  $10

      
        
        6
        +  assets:cash

      
        
        7
        +

      
        
        8
        +-- child.journal --

      
        
        9
        +06-15 lunch

      
        
        10
        +  expenses:food  $5

      
        
        11
        +  assets:cash

      
        
        12
        +

      
        
        13
        +-- expect --

      
        
        14
        +== in.journal ==

      
        
        15
        +  1 year 2025

      
        
        16
        +  2 include child.journal

      
        
        17
        +  3 2025-12-01 dinner

      
        
        18
        +== child.journal ==

      
        
        19
        +  1 2025-06-15 lunch