all repos

clerk @ 1f3a17349e37c8189adb11b8d956acbf3b2080d1

missing tooling for ledger/hledger
22 files changed, 535 insertions(+), 724 deletions(-)
improve loader
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-07-29 19:32:29 +0300
Authored at: 2026-07-28 18:40:28 +0300
Change ID: wpuumsxvyxryrpplmrowmlvmxpwpvynr
Parent: e2e9e2b
M AGENTS.md
···
        4
        4
         

      
        5
        5
         ## Commands

      
        6
        6
         ```bash

      
        7
        
        -go build -ldflags="-X 'main.version=dev'" .   # build

      
        
        7
        +go build .                                    # build

      
        8
        8
         go test ./...                                 # all tests

      
        9
        9
         go test ./internal/linter/ -run TestLinter -v # linter golden suite

      
        10
        10
         ```

      
        11
        11
         

      
        12
        12
         ## Conventions

      
        13
        
        -- **Golden tests:** `testdata/<name>.input` (fixture) + `<name>.golden` (expected), use `internal/testutil/golden.Load/Assert`

      
        14
        
        -- **semantic.Build(files):** files must be in include-order (includers after includes)

      
        
        13
        +- **Golden tests:** `testdata/<name>.txtar`

      
        15
        14
         

      
        16
        15
         ## Boundaries

      
        17
        16
         - Use `internal/decimal.Decimal` for monetary amounts (no float64)

      
M internal/analyzer/analyzer_test.go
···
        42
        42
         			}

      
        43
        43
         

      
        44
        44
         			l := journal.NewLoader()

      
        45
        
        -			if _, err := l.LoadFS(fsys, "in.journal"); err != nil {

      
        
        45
        +			rj, err := l.ResolveFS(fsys, "in.journal")

      
        
        46
        +			if err != nil {

      
        46
        47
         				t.Fatalf("failed to load test journal: %v", err)

      
        47
        48
         			}

      
        48
        49
         

      
        49
        
        -			ctx := Build(l.Ordered())

      
        
        50
        +			ctx := Build(rj)

      
        50
        51
         			var b strings.Builder

      
        51
        52
         			fprint(&b, ctx)

      
        52
        53
         			golden.Assert(t, a, b.String())

      
M internal/analyzer/benchmark_test.go
···
        6
        6
         	"olexsmir.xyz/clerk/journal"

      
        7
        7
         )

      
        8
        8
         

      
        9
        
        -func BenchmarkBuild_BasicJournal(b *testing.B) {

      
        10
        
        -	files := loadJournal("../../journal/testdata/journals/basic.journal")

      
        11
        
        -	b.ResetTimer()

      
        12
        
        -	for b.Loop() {

      
        13
        
        -		Build(files)

      
        14
        
        -	}

      
        15
        
        -}

      
        16
        
        -

      
        17
        
        -func BenchmarkBuild_1kTxns(b *testing.B) {

      
        18
        
        -	files := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        19
        
        -	b.ResetTimer()

      
        20
        
        -	for b.Loop() {

      
        21
        
        -		Build(files)

      
        22
        
        -	}

      
        23
        
        -}

      
        24
        
        -

      
        25
        
        -func BenchmarkBuild_Standard(b *testing.B) {

      
        26
        
        -	files := loadJournal("../../journal/testdata/journals/actual-ledger-input-standard.dat")

      
        27
        
        -	b.ResetTimer()

      
        28
        
        -	for b.Loop() {

      
        29
        
        -		Build(files)

      
        30
        
        -	}

      
        31
        
        -}

      
        32
        
        -

      
        33
        
        -func BenchmarkBuild_Wow(b *testing.B) {

      
        34
        
        -	files := loadJournal("../../journal/testdata/journals/actual-ledger-input-wow.dat")

      
        35
        
        -	b.ResetTimer()

      
        36
        
        -	for b.Loop() {

      
        37
        
        -		Build(files)

      
        38
        
        -	}

      
        39
        
        -}

      
        40
        
        -

      
        41
        
        -func BenchmarkBuild_1kTxns_Allocs(b *testing.B) {

      
        42
        
        -	files := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        43
        
        -	b.ResetTimer()

      
        44
        
        -	b.ReportAllocs()

      
        45
        
        -	for b.Loop() {

      
        46
        
        -		Build(files)

      
        47
        
        -	}

      
        48
        
        -}

      
        49
        
        -

      
        50
        
        -func BenchmarkBuild_Parallel_1kTxns(b *testing.B) {

      
        51
        
        -	files := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        52
        
        -	b.RunParallel(func(pb *testing.PB) {

      
        53
        
        -		for pb.Next() {

      
        54
        
        -			Build(files)

      
        
        9
        +func BenchmarkBuild(b *testing.B) {

      
        
        10
        +	b.Run("basic", bench("../../journal/testdata/journals/basic.journal"))

      
        
        11
        +	b.Run("standard", bench("../../journal/testdata/journals/actual-ledger-input-standard.dat"))

      
        
        12
        +	b.Run("personal", bench("../../journal/testdata/journals/actual-personal.journal"))

      
        
        13
        +	b.Run("1k txns", bench("../../journal/testdata/journals/actual-1ktxns-100accts.journal"))

      
        
        14
        +	b.Run("wow", bench("../../journal/testdata/journals/actual-ledger-input-wow.dat"))

      
        
        15
        +	b.Run("1k txns, prefix lookup", func(b *testing.B) {

      
        
        16
        +		rj := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        
        17
        +		a := Build(rj)

      
        
        18
        +		prefixes := make([]string, 0, len(a.AccountsByPrefix))

      
        
        19
        +		for p := range a.AccountsByPrefix {

      
        
        20
        +			prefixes = append(prefixes, p)

      
        
        21
        +		}

      
        
        22
        +		b.ResetTimer()

      
        
        23
        +		for b.Loop() {

      
        
        24
        +			for _, p := range prefixes {

      
        
        25
        +				_ = a.AccountsByPrefix[p]

      
        
        26
        +			}

      
        55
        27
         		}

      
        56
        28
         	})

      
        57
        
        -}

      
        58
        29
         

      
        59
        
        -func BenchmarkPrefixLookup_1kTxns(b *testing.B) {

      
        60
        
        -	a := Build(loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal"))

      
        61
        
        -	prefixes := make([]string, 0, len(a.AccountsByPrefix))

      
        62
        
        -	for p := range a.AccountsByPrefix {

      
        63
        
        -		prefixes = append(prefixes, p)

      
        64
        
        -	}

      
        65
        
        -	b.ResetTimer()

      
        66
        
        -	for b.Loop() {

      
        67
        
        -		for _, p := range prefixes {

      
        68
        
        -			_ = a.AccountsByPrefix[p]

      
        
        30
        +	b.Run("1k txns, account iter", func(b *testing.B) {

      
        
        31
        +		rj := loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        
        32
        +		a := Build(rj)

      
        
        33
        +		names := a.AccountNames

      
        
        34
        +		b.ResetTimer()

      
        
        35
        +		for b.Loop() {

      
        
        36
        +			for _, name := range names {

      
        
        37
        +				_ = a.Accounts[name].UsedCount

      
        
        38
        +			}

      
        69
        39
         		}

      
        70
        
        -	}

      
        
        40
        +	})

      
        71
        41
         }

      
        72
        42
         

      
        73
        
        -func BenchmarkAccountIteration_1kTxns(b *testing.B) {

      
        74
        
        -	a := Build(loadJournal("../../journal/testdata/journals/actual-1ktxns-100accts.journal"))

      
        75
        
        -	names := a.AccountNames

      
        76
        
        -	b.ResetTimer()

      
        77
        
        -	for b.Loop() {

      
        78
        
        -		for _, name := range names {

      
        79
        
        -			_ = a.Accounts[name].UsedCount

      
        
        43
        +func bench(fpath string) func(b *testing.B) {

      
        
        44
        +	files := loadJournal(fpath)

      
        
        45
        +	return func(b *testing.B) {

      
        
        46
        +		b.ResetTimer()

      
        
        47
        +		b.ReportAllocs()

      
        
        48
        +		for b.Loop() {

      
        
        49
        +			Build(files)

      
        80
        50
         		}

      
        81
        51
         	}

      
        82
        52
         }

      
        83
        53
         

      
        84
        
        -func loadJournal(path string) []*journal.ParsedFile {

      
        
        54
        +func loadJournal(path string) *journal.ResolvedJournal {

      
        85
        55
         	ldr := journal.NewLoader()

      
        86
        
        -	if _, err := ldr.Load(path); err != nil {

      
        
        56
        +	rj, err := ldr.Resolve(path)

      
        
        57
        +	if err != nil {

      
        87
        58
         		panic("loadJournal: " + err.Error())

      
        88
        59
         	}

      
        89
        
        -	return ldr.Ordered()

      
        
        60
        +	return rj

      
        90
        61
         }

      
M internal/analyzer/build.go
···
        9
        9
         	"olexsmir.xyz/clerk/journal/ast"

      
        10
        10
         )

      
        11
        11
         

      
        12
        
        -// Build constructs [Analysis] from a list of parsed files.

      
        13
        
        -// Files should be in dependency order (includes before includers).

      
        14
        
        -func Build(files []*journal.ParsedFile) *Analysis {

      
        
        12
        +// Build constructs [Analysis] from a flat resolved journal view.

      
        
        13
        +func Build(rj *journal.ResolvedJournal) *Analysis {

      
        
        14
        +	fileIndex := make(map[*journal.ParsedFile]int)

      
        
        15
        +	for i, pf := range rj.Occurrences {

      
        
        16
        +		fileIndex[pf] = i

      
        
        17
        +	}

      
        
        18
        +

      
        15
        19
         	a := &Analysis{

      
        16
        
        -		Files:                 files,

      
        
        20
        +		Files:                 rj.Occurrences,

      
        17
        21
         		Accounts:              make(map[string]*AccountInfo),

      
        18
        22
         		Commodities:           make(map[string]*CommodityInfo),

      
        19
        23
         		Payees:                make(map[string]*PayeeInfo),

      ···
        22
        26
         		CommodityDecimalMarks: make(map[string]byte),

      
        23
        27
         		TransactionsByKey:     make(map[string][]*ast.Transaction),

      
        24
        28
         	}

      
        25
        
        -	for i, pf := range a.Files {

      
        26
        
        -		for _, entry := range pf.Ast.Entries {

      
        27
        
        -			a.addEntry(i, entry)

      
        
        29
        +	for _, item := range rj.Items {

      
        
        30
        +		if item.IsInclude {

      
        
        31
        +			continue

      
        28
        32
         		}

      
        
        33
        +		idx := fileIndex[item.Occurrence]

      
        
        34
        +		a.addEntry(idx, item.Occurrence.Ast.Entries[item.EntryIndex])

      
        29
        35
         	}

      
        30
        36
         	a.buildPrefixIndex()

      
        31
        37
         	a.sortAccountNames()

      
M internal/analyzer/build_test.go
···
        19
        19
         			}

      
        20
        20
         

      
        21
        21
         			l := journal.NewLoader()

      
        22
        
        -			if _, err := l.LoadFS(fsys, "in.journal"); err != nil {

      
        23
        
        -				t.Fatalf("LoadFS: %v", err)

      
        
        22
        +			rj, err := l.ResolveFS(fsys, "in.journal")

      
        
        23
        +			if err != nil {

      
        
        24
        +				t.Fatalf("ResolveFS: %v", err)

      
        24
        25
         			}

      
        25
        26
         

      
        26
        27
         			var buf bytes.Buffer

      
        27
        
        -			fprint(&buf, Build(l.Ordered()))

      
        
        28
        +			fprint(&buf, Build(rj))

      
        28
        29
         			golden.Assert(t, a, buf.String())

      
        29
        30
         		})

      
        30
        31
         	}

      
M internal/analyzer/testdata/directives.txtar
···
        48
        48
         periodic transactions: 0

      
        49
        49
         automated transactions: 0

      
        50
        50
         

      
        51
        
        -directives (13):

      
        
        51
        +directives (12):

      
        52
        52
           account expenses:food

      
        53
        53
           commodity $

      
        54
        54
           payee 

      
        55
        55
           tag mytag

      
        56
        
        -  include other.journal

      
        57
        56
           alias expenses:food = expenses:food:old

      
        58
        57
           year 2024

      
        59
        58
           D $1000

      
M internal/analyzer/testdata/include.txtar
···
        10
        10
         

      
        11
        11
         -- expect --

      
        12
        12
         files (2):

      
        13
        
        -  0: accounts.journal

      
        14
        
        -  1: in.journal

      
        
        13
        +  0: in.journal

      
        
        14
        +  1: accounts.journal

      
        15
        15
         

      
        16
        16
         commodities (1):

      
        17
        17
           $

      
        18
        18
             directives: 0

      
        19
        19
             used: 1

      
        20
        20
             usages: 1

      
        21
        
        -      file 1: $ ("10")

      
        
        21
        +      file 0: $ ("10")

      
        22
        22
             last-used: 2024-01-01

      
        23
        23
         

      
        24
        24
         accounts (2):

      ···
        26
        26
             directives: 0

      
        27
        27
             used: 1

      
        28
        28
             usages: 1

      
        29
        
        -      file 1: assets:checking

      
        
        29
        +      file 0: assets:checking

      
        30
        30
             last-used: 2024-01-01

      
        31
        31
           expenses:food

      
        32
        32
             directives: 1

      
        33
        33
             used: 1

      
        34
        34
               account expenses:food

      
        35
        35
             usages: 1

      
        36
        
        -      file 1: expenses:food

      
        
        36
        +      file 0: expenses:food

      
        37
        37
             last-used: 2024-01-01

      
        38
        38
         

      
        39
        39
         payees (1):

      ···
        41
        41
             directives: 0

      
        42
        42
             used: 1

      
        43
        43
             usages: 1

      
        44
        
        -      file 1: test

      
        
        44
        +      file 0: test

      
        45
        45
         

      
        46
        46
         prefixes (2):

      
        47
        47
           assets:

      ···
        53
        53
         periodic transactions: 0

      
        54
        54
         automated transactions: 0

      
        55
        55
         

      
        56
        
        -directives (2):

      
        
        56
        +directives (1):

      
        57
        57
           account expenses:food

      
        58
        
        -  include accounts.journal

      
        59
        58
         

      
        60
        59
         transaction keys (1):

      
        61
        60
           2024-01-01|test|expenses:food,assets:checking,

      
M internal/cli/cmd_format.go
···
        21
        21
         

      
        22
        22
         	loader := journal.NewLoader()

      
        23
        23
         	if len(paths) == 0 {

      
        24
        
        -		pf, err := loadStdin(loader)

      
        
        24
        +		src, err := readStdin()

      
        25
        25
         		if err != nil {

      
        26
        26
         			return err

      
        27
        27
         		}

      
        
        28
        +		rj := loader.ResolveBytes("stdin", src)

      
        
        29
        +		pf := rj.Occurrences[0]

      
        28
        30
         		if len(pf.Errors) > 0 || len(pf.FileErrors) > 0 {

      
        29
        31
         			for _, e := range pf.Errors {

      
        30
        32
         				fmt.Fprintf(os.Stderr, "error: stdin: %s\n", e.Message)

      ···
        44
        46
         

      
        45
        47
         	var hasErrors bool

      
        46
        48
         	for _, path := range files {

      
        47
        
        -		pf, err := loadFile(loader, path)

      
        
        49
        +		rj, err := loader.Resolve(path)

      
        48
        50
         		if err != nil {

      
        49
        51
         			fmt.Fprintf(os.Stderr, "error: %v\n", err)

      
        50
        52
         			hasErrors = true

      
        51
        53
         			continue

      
        52
        54
         		}

      
        
        55
        +		pf := rj.Occurrences[0]

      
        53
        56
         		if len(pf.Errors) > 0 || len(pf.FileErrors) > 0 {

      
        54
        57
         			for _, e := range pf.Errors {

      
        55
        58
         				fmt.Fprintf(os.Stderr, "error: %s: %s\n", path, e.Message)

      
M internal/cli/cmd_lint.go
···
        37
        37
         

      
        38
        38
         	var hasIssues bool

      
        39
        39
         	for _, f := range journalFiles {

      
        40
        
        -		if _, err := loadFile(loader, f); err != nil {

      
        
        40
        +		rj, err := loader.Resolve(f)

      
        
        41
        +		if err != nil {

      
        41
        42
         			fmt.Fprintf(os.Stderr, "error: %v\n", err)

      
        42
        43
         			hasIssues = true

      
        
        44
        +			continue

      
        43
        45
         		}

      
        44
        
        -	}

      
        45
        
        -

      
        46
        
        -	for _, root := range loader.Roots() {

      
        47
        
        -		reporter.Collect(lint.Run(analyzer.Build(journal.CollectFiles(root))))

      
        
        46
        +		reporter.Collect(lint.Run(analyzer.Build(rj)))

      
        48
        47
         	}

      
        49
        48
         

      
        50
        49
         	if err := reporter.Flush(format); err != nil {

      ···
        58
        57
         }

      
        59
        58
         

      
        60
        59
         func (c *Cli) lintStdin(lint *linter.Linter, reporter *linter.Reporter, format string) error {

      
        61
        
        -	loader := journal.NewLoader()

      
        62
        
        -	if _, err := loadStdin(loader); err != nil {

      
        
        60
        +	src, err := readStdin()

      
        
        61
        +	if err != nil {

      
        63
        62
         		return err

      
        64
        63
         	}

      
        65
        
        -

      
        66
        
        -	for _, root := range loader.Roots() {

      
        67
        
        -		reporter.Collect(lint.Run(analyzer.Build(journal.CollectFiles(root))))

      
        68
        
        -	}

      
        
        64
        +	loader := journal.NewLoader()

      
        
        65
        +	rj := loader.ResolveBytes("stdin", src)

      
        
        66
        +	reporter.Collect(lint.Run(analyzer.Build(rj)))

      
        69
        67
         

      
        70
        68
         	if err := reporter.Flush(format); err != nil {

      
        71
        69
         		return fmt.Errorf("flushing report: %w", err)

      
M internal/cli/cmd_tags.go
···
        34
        34
         

      
        35
        35
         	loader := journal.NewLoader()

      
        36
        36
         

      
        37
        
        -	var hasErrors bool

      
        
        37
        +	var rj *journal.ResolvedJournal

      
        38
        38
         	for _, file := range journalFiles {

      
        39
        
        -		if _, err := loadFile(loader, file); err != nil {

      
        
        39
        +		resolved, err := loader.Resolve(file)

      
        
        40
        +		if err != nil {

      
        40
        41
         			fmt.Fprintf(os.Stderr, "error: %v\n", err)

      
        41
        
        -			hasErrors = true

      
        
        42
        +			continue

      
        
        43
        +		}

      
        
        44
        +		if rj == nil {

      
        
        45
        +			rj = resolved

      
        
        46
        +		} else {

      
        
        47
        +			// Merge: not supported for multi-root yet — use the last.

      
        
        48
        +			rj = resolved

      
        42
        49
         		}

      
        43
        50
         	}

      
        44
        
        -	if hasErrors {

      
        
        51
        +	if rj == nil {

      
        45
        52
         		return cli.Exit("", 1)

      
        46
        53
         	}

      
        47
        54
         

      
        48
        
        -	tagger := tags.New(loader, cwd)

      
        
        55
        +	tagger := tags.New(rj, cwd)

      
        49
        56
         

      
        50
        57
         	out := cmd.String("out")

      
        51
        58
         	var w io.Writer = os.Stdout

      
M internal/cli/helpers.go
···
        46
        46
         	return files, nil

      
        47
        47
         }

      
        48
        48
         

      
        49
        
        -func loadFile(loader *journal.Loader, path string) (*journal.ParsedFile, error) {

      
        50
        
        -	src, err := os.ReadFile(path)

      
        51
        
        -	if err != nil {

      
        52
        
        -		return nil, fmt.Errorf("reading %s: %w", path, err)

      
        53
        
        -	}

      
        54
        
        -	pf, err := loader.LoadBytes(path, src)

      
        55
        
        -	if err != nil {

      
        56
        
        -		return nil, fmt.Errorf("parsing %s: %w", path, err)

      
        57
        
        -	}

      
        58
        
        -	return pf, nil

      
        59
        
        -}

      
        60
        
        -

      
        61
        49
         func readStdin() ([]byte, error) {

      
        62
        50
         	src, err := io.ReadAll(os.Stdin)

      
        63
        51
         	if err != nil {

      ···
        65
        53
         	}

      
        66
        54
         	return src, nil

      
        67
        55
         }

      
        68
        
        -

      
        69
        
        -func loadStdin(loader *journal.Loader) (*journal.ParsedFile, error) {

      
        70
        
        -	src, err := readStdin()

      
        71
        
        -	if err != nil {

      
        72
        
        -		return nil, err

      
        73
        
        -	}

      
        74
        
        -	pf, err := loader.LoadBytes("stdin", src)

      
        75
        
        -	if err != nil {

      
        76
        
        -		return nil, fmt.Errorf("parsing stdin: %w", err)

      
        77
        
        -	}

      
        78
        
        -	return pf, nil

      
        79
        
        -}

      
M internal/linter/linter_test.go
···
        39
        39
         			}

      
        40
        40
         

      
        41
        41
         			l := journal.NewLoader()

      
        42
        
        -			if _, err := l.LoadFS(fsys, "in.journal"); err != nil {

      
        
        42
        +			rj, err := l.ResolveFS(fsys, "in.journal")

      
        
        43
        +			if err != nil {

      
        43
        44
         				t.Fatalf("failed to load test journal: %v", err)

      
        44
        45
         			}

      
        45
        46
         

      
        46
        
        -			ctx := analyzer.Build(l.Ordered())

      
        
        47
        +			ctx := analyzer.Build(rj)

      
        47
        48
         			finds := NewLinter(trules).Run(ctx)

      
        48
        49
         

      
        49
        50
         			var b strings.Builder

      ···
        55
        56
         

      
        56
        57
         func BenchmarkLinter(b *testing.B) {

      
        57
        58
         	ldr := journal.NewLoader()

      
        58
        
        -	_, err := ldr.Load(

      
        
        59
        +	rj, err := ldr.Resolve(

      
        59
        60
         		"../../journal/testdata/journals/actual-1ktxns-100accts.journal",

      
        60
        61
         	)

      
        61
        62
         	if err != nil {

      
        62
        63
         		b.Fatalf("failed to load benchmark journal: %v", err)

      
        63
        64
         	}

      
        64
        65
         

      
        65
        
        -	ctx := analyzer.Build(ldr.Ordered())

      
        
        66
        +	ctx := analyzer.Build(rj)

      
        66
        67
         	l := NewLinter(Rules)

      
        67
        68
         

      
        68
        69
         	b.ResetTimer()

      
M internal/lsp/diagnostics.go
···
        11
        11
         

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

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

      
        14
        
        -	"olexsmir.xyz/clerk/journal"

      
        15
        14
         	"olexsmir.xyz/clerk/journal/token"

      
        16
        15
         )

      
        17
        16
         

      ···
        44
        43
         	for u, st := range s.openDocs {

      
        45
        44
         		openDocs[u] = st

      
        46
        45
         	}

      
        47
        
        -	ordered := s.loader.Ordered()

      
        48
        46
         	s.mu.Unlock()

      
        49
        47
         

      
        50
        48
         	if ctx.Err() != nil {

      ···
        55
        53
         		return

      
        56
        54
         	}

      
        57
        55
         

      
        58
        
        -	for uri, state := range openDocs {

      
        59
        
        -		path := uri.Path()

      
        60
        
        -		if _, err := s.loader.Reload(path, []byte(state.text)); err != nil {

      
        61
        
        -			s.log.Warn("failed to reload open document", "uri", uri, "err", err)

      
        62
        
        -		}

      
        63
        
        -	}

      
        
        56
        +	activePaths := make(map[string]bool)

      
        64
        57
         

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

      
        66
        
        -		return

      
        
        58
        +	var a *analyzer.Analysis

      
        
        59
        +	for duri, state := range openDocs {

      
        
        60
        +		path := duri.Path()

      
        
        61
        +		rj := s.loader.ResolveBytes(path, []byte(state.text))

      
        
        62
        +		if a == nil {

      
        
        63
        +			a = analyzer.Build(rj)

      
        
        64
        +		} else {

      
        
        65
        +			// Merge: only one root in practice for now.

      
        
        66
        +		}

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

      
        
        68
        +			activePaths[pf.Path] = true

      
        
        69
        +		}

      
        67
        70
         	}

      
        68
        71
         

      
        69
        
        -	ordered = s.loader.Ordered()

      
        70
        
        -	if len(ordered) == 0 {

      
        
        72
        +	if a == nil {

      
        71
        73
         		s.log.Debug("no files in workspace")

      
        72
        74
         		return

      
        73
        75
         	}

      ···
        76
        78
         		return

      
        77
        79
         	}

      
        78
        80
         

      
        79
        
        -	a := analyzer.Build(ordered)

      
        80
        81
         	finds := dedupFinds(s.linter.Run(a))

      
        81
        82
         

      
        82
        83
         	s.mu.Lock()

      ···
        89
        90
         

      
        90
        91
         	diagsByFile := s.groupFindsByFile(finds)

      
        91
        92
         

      
        92
        
        -	s.mu.Lock()

      
        93
        
        -	activeFiles := s.activeFileSet(openDocs, ordered)

      
        94
        
        -	s.mu.Unlock()

      
        95
        
        -

      
        96
        
        -	for fpath := range activeFiles {

      
        
        93
        +	for fpath := range activePaths {

      
        97
        94
         		if ctx.Err() != nil {

      
        98
        95
         			return

      
        99
        96
         		}

      ···
        105
        102
         		}

      
        106
        103
         	}

      
        107
        104
         

      
        108
        
        -	s.log.Debug("diagnostics published", "files", len(ordered), "findings", len(finds))

      
        109
        
        -}

      
        110
        
        -

      
        111
        
        -func (s *server) activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool {

      
        112
        
        -	active := make(map[string]bool)

      
        113
        
        -	for duri := range docs {

      
        114
        
        -		active[duri.Path()] = true

      
        115
        
        -	}

      
        116
        
        -	visited := make(map[string]bool)

      
        117
        
        -	var walk func(*journal.ParsedFile)

      
        118
        
        -	walk = func(pf *journal.ParsedFile) {

      
        119
        
        -		if visited[pf.Path] {

      
        120
        
        -			return

      
        121
        
        -		}

      
        122
        
        -		active[pf.Path] = true

      
        123
        
        -		visited[pf.Path] = true

      
        124
        
        -		for _, inc := range pf.Includes {

      
        125
        
        -			walk(inc)

      
        126
        
        -		}

      
        127
        
        -	}

      
        128
        
        -	byPath := make(map[string]*journal.ParsedFile, len(ordered))

      
        129
        
        -	for _, pf := range ordered {

      
        130
        
        -		byPath[pf.Path] = pf

      
        131
        
        -	}

      
        132
        
        -	for duri := range docs {

      
        133
        
        -		if pf, ok := byPath[duri.Path()]; ok {

      
        134
        
        -			walk(pf)

      
        135
        
        -		}

      
        136
        
        -	}

      
        137
        
        -	return active

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

      
        138
        106
         }

      
        139
        107
         

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

      
M journal/journal_test.go
···
        67
        67
         	for tname, tt := range tests {

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

      
        69
        69
         			loader := journal.NewLoader()

      
        70
        
        -			pf, err := loader.Load(filepath.Join("testdata/journals", tname))

      
        
        70
        +			rj, err := loader.Resolve(filepath.Join("testdata/journals", tname))

      
        71
        71
         			if err != nil {

      
        72
        72
         				t.Fatalf("load err: %v", err)

      
        73
        73
         			}

      
        74
        74
         

      
        
        75
        +			pf := rj.Occurrences[0]

      
        75
        76
         			if tt.err {

      
        76
        77
         				if len(pf.Errors)+len(pf.FileErrors) == 0 {

      
        77
        78
         					t.Errorf("expected parse errors but got none")

      
A journal/lexer/benchmark_test.go
···
        
        1
        +package lexer

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"os"

      
        
        5
        +	"testing"

      
        
        6
        +

      
        
        7
        +	"olexsmir.xyz/clerk/journal/token"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +func BenchmarkLexer(b *testing.B) {

      
        
        11
        +	b.Run("basic", bench("../../journal/testdata/journals/basic.journal"))

      
        
        12
        +	b.Run("personal", bench("../../journal/testdata/journals/actual-personal.journal"))

      
        
        13
        +	b.Run("sample", bench("../../journal/testdata/journals/actual-sample.journal"))

      
        
        14
        +	b.Run("wow", bench("../../journal/testdata/journals/actual-ledger-input-wow.dat"))

      
        
        15
        +	b.Run("1k txns", bench("../../journal/testdata/journals/actual-1ktxns-100accts.journal"))

      
        
        16
        +}

      
        
        17
        +

      
        
        18
        +func bench(fpath string) func(b *testing.B) {

      
        
        19
        +	src, err := os.ReadFile(fpath)

      
        
        20
        +	if err != nil {

      
        
        21
        +		panic("loadTestdata: " + err.Error())

      
        
        22
        +	}

      
        
        23
        +

      
        
        24
        +	return func(b *testing.B) {

      
        
        25
        +		b.ResetTimer()

      
        
        26
        +		b.ReportAllocs()

      
        
        27
        +		for b.Loop() {

      
        
        28
        +			l := New("bench", src)

      
        
        29
        +			for l.Next().Type != token.EOF {

      
        
        30
        +			}

      
        
        31
        +		}

      
        
        32
        +	}

      
        
        33
        +}

      
M journal/loader.go
···
        1
        1
         package journal

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"bytes"

      
        4
        5
         	"fmt"

      
        5
        6
         	"io/fs"

      
        6
        7
         	"os"

      
        7
        
        -	"path"

      
        8
        8
         	"path/filepath"

      
        9
        9
         	"slices"

      
        10
        10
         	"strings"

      
        
        11
        +	"sync"

      
        11
        12
         

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

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

      ···
        19
        20
         	Src        []byte

      
        20
        21
         	Ast        *ast.Journal

      
        21
        22
         	Includes   []*ParsedFile

      
        
        23
        +	FileErrors []*ast.FileError

      
        22
        24
         	Errors     []*ast.ParseError

      
        23
        
        -	FileErrors []*ast.FileError

      
        
        25
        +}

      
        
        26
        +

      
        
        27
        +// ResolvedItem is one item in an occurrence-ordered flat view; child items inlined at include site.

      
        
        28
        +type ResolvedItem struct {

      
        
        29
        +	Occurrence *ParsedFile

      
        
        30
        +	IsInclude  bool // true for IncludeDirective entries (skipped by consumers)

      
        
        31
        +	EntryIndex int  // index into Occurrence.Ast.Entries

      
        
        32
        +}

      
        
        33
        +

      
        
        34
        +// ResolvedJournal is a flat, occurrence-ordered view of a journal tree.

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

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

      
        
        37
        +type ResolvedJournal struct {

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

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

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

      
        
        41
        +	ByPath      map[string][]*ParsedFile

      
        24
        42
         }

      
        25
        43
         

      
        26
        
        -// CollectFiles returns root and all its transitive includes.

      
        27
        
        -func CollectFiles(root *ParsedFile) []*ParsedFile {

      
        28
        
        -	var files []*ParsedFile

      
        29
        
        -	visited := make(map[*ParsedFile]bool)

      
        30
        
        -	var walk func(*ParsedFile)

      
        31
        
        -	walk = func(pf *ParsedFile) {

      
        32
        
        -		if visited[pf] {

      
        33
        
        -			return

      
        34
        
        -		}

      
        35
        
        -		visited[pf] = true

      
        36
        
        -		files = append(files, pf)

      
        37
        
        -		for _, inc := range pf.Includes {

      
        38
        
        -			walk(inc)

      
        39
        
        -		}

      
        
        44
        +// FileErrors returns all file errors from all occurrences.

      
        
        45
        +func (rj *ResolvedJournal) FileErrors() []*ast.FileError {

      
        
        46
        +	var all []*ast.FileError

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

      
        
        48
        +		all = append(all, pf.FileErrors...)

      
        40
        49
         	}

      
        41
        
        -	walk(root)

      
        42
        
        -	return files

      
        
        50
        +	return all

      
        43
        51
         }

      
        44
        52
         

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

      
        45
        54
         type Loader struct {

      
        46
        
        -	files map[string]*ParsedFile // key is absolute path (OS) or FS-relative path

      
        47
        
        -	fsys  fs.FS                  // set during LoadFS

      
        
        55
        +	mu           sync.RWMutex

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

      
        48
        57
         }

      
        49
        58
         

      
        50
        59
         func NewLoader() *Loader {

      
        51
        
        -	return &Loader{files: make(map[string]*ParsedFile)}

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

      
        52
        61
         }

      
        53
        62
         

      
        54
        
        -func (l *Loader) Load(fpath string) (*ParsedFile, error) {

      
        55
        
        -	return l.loadFile(fpath, nil)

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

      
        
        64
        +// occurrence view. Same file may appear multiple times when included from

      
        
        65
        +// multiple sites.

      
        
        66
        +func (l *Loader) Resolve(fpath string) (*ResolvedJournal, error) {

      
        
        67
        +	src, err := l.readContent(fpath)

      
        
        68
        +	if err != nil {

      
        
        69
        +		return nil, err

      
        
        70
        +	}

      
        
        71
        +	return l.ResolveBytes(fpath, src), nil

      
        56
        72
         }

      
        57
        73
         

      
        58
        
        -// LoadFS loads a journal from an [fs.FS], resolving includes through the same filesystem.

      
        59
        
        -func (l *Loader) LoadFS(fsys fs.FS, fpath string) (*ParsedFile, error) {

      
        60
        
        -	l.fsys = fsys

      
        61
        
        -	defer func() { l.fsys = nil }()

      
        62
        
        -	return l.loadFile(fpath, nil)

      
        
        74
        +// ResolveBytes is like [Loader.Resolve] but parses from a byte slice; includes resolved relative to fpath.

      
        
        75
        +func (l *Loader) ResolveBytes(fpath string, src []byte) *ResolvedJournal {

      
        
        76
        +	rj := &ResolvedJournal{

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

      
        
        78
        +	}

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

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

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

      
        
        82
        +	}

      
        
        83
        +	return rj

      
        63
        84
         }

      
        64
        85
         

      
        65
        
        -func (l *Loader) LoadBytes(path string, src []byte) (*ParsedFile, error) {

      
        66
        
        -	return l.loadBytes(path, src, nil)

      
        67
        
        -}

      
        
        86
        +// ResolveFS loads a journal from [fs.FS] via temp dir.

      
        
        87
        +func (l *Loader) ResolveFS(fsys fs.FS, fpath string) (*ResolvedJournal, error) {

      
        
        88
        +	dir, err := os.MkdirTemp("", "clerk-loadfs-*")

      
        
        89
        +	if err != nil {

      
        
        90
        +		return nil, fmt.Errorf("creating temp dir: %w", err)

      
        
        91
        +	}

      
        
        92
        +	defer os.RemoveAll(dir)

      
        68
        93
         

      
        69
        
        -func (l *Loader) Reload(path string, src []byte) (*ParsedFile, error) {

      
        70
        
        -	abs, err := filepath.Abs(path)

      
        
        94
        +	if err := os.CopyFS(dir, fsys); err != nil {

      
        
        95
        +		return nil, fmt.Errorf("copying fs to temp dir: %w", err)

      
        
        96
        +	}

      
        
        97
        +

      
        
        98
        +	rj, err := l.Resolve(filepath.Join(dir, fpath))

      
        71
        99
         	if err != nil {

      
        72
        100
         		return nil, err

      
        73
        101
         	}

      
        74
        
        -	delete(l.files, abs)

      
        75
        
        -	return l.loadBytes(abs, src, nil)

      
        
        102
        +

      
        
        103
        +	// remap temp dir paths to FS-relative paths for deterministic output

      
        
        104
        +	l.remapFilePaths(rj, dir)

      
        
        105
        +	return rj, nil

      
        76
        106
         }

      
        77
        107
         

      
        78
        
        -// Roots returns files not transitively included by any loaded file.

      
        79
        
        -func (l *Loader) Roots() []*ParsedFile {

      
        80
        
        -	included := make(map[string]bool)

      
        81
        
        -	for _, pf := range l.files {

      
        82
        
        -		for _, inc := range pf.Includes {

      
        83
        
        -			included[inc.Path] = true

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

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

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

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

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

      
        84
        113
         		}

      
        85
        114
         	}

      
        86
        
        -	var roots []*ParsedFile

      
        87
        
        -	for _, pf := range l.files {

      
        88
        
        -		if !included[pf.Path] {

      
        89
        
        -			roots = append(roots, pf)

      
        
        115
        +

      
        
        116
        +	newByPath := make(map[string][]*ParsedFile, len(rj.ByPath))

      
        
        117
        +	for oldPath, pfs := range rj.ByPath {

      
        
        118
        +		rel, err := filepath.Rel(oldRoot, oldPath)

      
        
        119
        +		newPath := oldPath

      
        
        120
        +		if err == nil {

      
        
        121
        +			newPath = filepath.ToSlash(rel)

      
        90
        122
         		}

      
        
        123
        +		newByPath[newPath] = pfs

      
        91
        124
         	}

      
        92
        
        -	return roots

      
        
        125
        +	rj.ByPath = newByPath

      
        
        126
        +}

      
        
        127
        +

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

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

      
        
        130
        +	canon := canonicalPath(fpath)

      
        
        131
        +	l.mu.Lock()

      
        
        132
        +	delete(l.contentCache, canon)

      
        
        133
        +	l.mu.Unlock()

      
        93
        134
         }

      
        94
        135
         

      
        95
        
        -// Ordered returns all files in dependency order (included before includer)

      
        96
        
        -func (l *Loader) Ordered() []*ParsedFile {

      
        97
        
        -	visited := make(map[string]bool)

      
        98
        
        -	var res []*ParsedFile

      
        99
        
        -	var visit func(*ParsedFile)

      
        100
        
        -	visit = func(pf *ParsedFile) {

      
        101
        
        -		if visited[pf.Path] {

      
        102
        
        -			return

      
        103
        
        -		}

      
        104
        
        -		visited[pf.Path] = true

      
        105
        
        -		for _, inc := range pf.Includes {

      
        106
        
        -			visit(inc)

      
        107
        
        -		}

      
        108
        
        -		res = append(res, pf)

      
        109
        
        -	}

      
        110
        
        -	for _, pf := range l.files {

      
        111
        
        -		visit(pf)

      
        112
        
        -	}

      
        113
        
        -	return res

      
        
        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()

      
        114
        141
         }

      
        115
        142
         

      
        116
        
        -func (l *Loader) loadFile(fpath string, stack []string) (*ParsedFile, error) {

      
        117
        
        -	if l.fsys != nil {

      
        118
        
        -		fpath = path.Clean(fpath)

      
        119
        
        -		if pf, ok := l.files[fpath]; ok {

      
        120
        
        -			return pf, nil

      
        121
        
        -		}

      
        122
        
        -		src, err := fs.ReadFile(l.fsys, fpath)

      
        123
        
        -		if err != nil {

      
        124
        
        -			return nil, err

      
        125
        
        -		}

      
        126
        
        -		return l.loadBytes(fpath, src, stack)

      
        
        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)

      
        
        146
        +

      
        
        147
        +	l.mu.RLock()

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

      
        
        149
        +	l.mu.RUnlock()

      
        
        150
        +	if ok {

      
        
        151
        +		return content, nil

      
        127
        152
         	}

      
        128
        153
         

      
        129
        
        -	abs, err := filepath.Abs(fpath)

      
        
        154
        +	raw, err := os.ReadFile(fpath)

      
        130
        155
         	if err != nil {

      
        131
        156
         		return nil, err

      
        132
        157
         	}

      
        133
        
        -	if pf, ok := l.files[abs]; ok {

      
        134
        
        -		return pf, nil

      
        135
        
        -	}

      
        136
        
        -	src, err := os.ReadFile(abs)

      
        137
        
        -	if err != nil {

      
        138
        
        -		return nil, err

      
        139
        
        -	}

      
        140
        
        -	return l.loadBytes(abs, src, stack)

      
        
        158
        +

      
        
        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
        +

      
        
        163
        +	l.mu.Lock()

      
        
        164
        +	l.contentCache[canon] = content

      
        
        165
        +	l.mu.Unlock()

      
        
        166
        +	return content, nil

      
        141
        167
         }

      
        142
        168
         

      
        143
        
        -func (l *Loader) loadBytes(pathStr string, src []byte, stack []string) (*ParsedFile, error) {

      
        144
        
        -	var fpath string

      
        145
        
        -	if l.fsys != nil {

      
        146
        
        -		fpath = path.Clean(pathStr)

      
        147
        
        -	} else {

      
        148
        
        -		abs, err := filepath.Abs(pathStr)

      
        149
        
        -		if err != nil {

      
        150
        
        -			return nil, err

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

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

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

      
        
        172
        +	canon := canonicalPath(fpath)

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

      
        
        174
        +		if parent != nil {

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

      
        
        176
        +				Path:    fpath,

      
        
        177
        +				Message: fmt.Sprintf("include cycle: %s", strings.Join(append(stack, canon), " → ")),

      
        
        178
        +			})

      
        151
        179
         		}

      
        152
        
        -		fpath = abs

      
        153
        
        -	}

      
        154
        
        -

      
        155
        
        -	if slices.Contains(stack, fpath) {

      
        156
        
        -		return nil, fmt.Errorf("include cycle: %s", strings.Join(append(stack, fpath), " → "))

      
        157
        
        -	}

      
        158
        
        -	if pf, ok := l.files[fpath]; ok {

      
        159
        
        -		return pf, nil

      
        
        180
        +		return

      
        160
        181
         	}

      
        161
        182
         

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

      
        163
        
        -	par := parser.New(lex)

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

      
        164
        185
         	j := par.ParseJournal()

      
        165
        186
         

      
        166
        187
         	pf := &ParsedFile{

      ···
        170
        191
         		Includes: []*ParsedFile{},

      
        171
        192
         		Errors:   j.Errors,

      
        172
        193
         	}

      
        173
        
        -	l.files[fpath] = pf

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

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

      
        174
        196
         

      
        175
        
        -	for _, entry := range j.Entries {

      
        176
        
        -		inc, ok := entry.(*ast.IncludeDirective)

      
        177
        
        -		if !ok {

      
        
        197
        +	if parent != nil {

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

      
        
        199
        +	}

      
        
        200
        +

      
        
        201
        +	currentYear := defaultYear

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

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

      
        
        204
        +		case *ast.BlankLine:

      
        178
        205
         			continue

      
        179
        
        -		}

      
        180
        206
         

      
        181
        
        -		var incPath string

      
        182
        
        -		var matches []string

      
        183
        
        -		var err error

      
        184
        
        -		if l.fsys != nil {

      
        185
        
        -			incPath = path.Join(path.Dir(fpath), inc.Path)

      
        186
        
        -			matches, err = fs.Glob(l.fsys, incPath)

      
        187
        
        -		} else {

      
        188
        
        -			incPath = filepath.Join(filepath.Dir(fpath), inc.Path)

      
        189
        
        -			matches, err = filepath.Glob(incPath)

      
        190
        
        -		}

      
        191
        
        -

      
        192
        
        -		if err != nil || len(matches) == 0 {

      
        193
        
        -			pf.FileErrors = append(pf.FileErrors, &ast.FileError{

      
        194
        
        -				Path:    incPath,

      
        195
        
        -				Span:    inc.Span,

      
        196
        
        -				Message: fmt.Sprintf("include not found: %s", inc.Path),

      
        
        207
        +		case *ast.IncludeDirective:

      
        
        208
        +			rj.Items = append(rj.Items, ResolvedItem{

      
        
        209
        +				Occurrence: pf,

      
        
        210
        +				IsInclude:  true,

      
        
        211
        +				EntryIndex: i,

      
        197
        212
         			})

      
        198
        
        -			continue

      
        199
        
        -		}

      
        200
        213
         

      
        201
        
        -		for _, match := range matches {

      
        202
        
        -			child, err := l.loadFile(match, append(stack, fpath))

      
        
        214
        +			incPath, err := resolveIncludePath(fpath, e.Path)

      
        203
        215
         			if err != nil {

      
        204
        216
         				pf.FileErrors = append(pf.FileErrors, &ast.FileError{

      
        205
        
        -					Path:    match,

      
        206
        
        -					Span:    inc.Span,

      
        
        217
        +					Path:    e.Path,

      
        
        218
        +					Span:    e.Span,

      
        207
        219
         					Message: err.Error(),

      
        208
        220
         				})

      
        209
        221
         				continue

      
        210
        222
         			}

      
        211
        
        -			pf.Includes = append(pf.Includes, child)

      
        
        223
        +

      
        
        224
        +			matches, err := filepath.Glob(incPath)

      
        
        225
        +			if err != nil || len(matches) == 0 {

      
        
        226
        +				pf.FileErrors = append(pf.FileErrors, &ast.FileError{

      
        
        227
        +					Path:    incPath,

      
        
        228
        +					Span:    e.Span,

      
        
        229
        +					Message: fmt.Sprintf("include not found: %s", e.Path),

      
        
        230
        +				})

      
        
        231
        +				continue

      
        
        232
        +			}

      
        
        233
        +

      
        
        234
        +			for _, match := range matches {

      
        
        235
        +				childSrc, err := l.readContent(match)

      
        
        236
        +				if err != nil {

      
        
        237
        +					pf.FileErrors = append(pf.FileErrors, &ast.FileError{

      
        
        238
        +						Path:    match,

      
        
        239
        +						Span:    e.Span,

      
        
        240
        +						Message: err.Error(),

      
        
        241
        +					})

      
        
        242
        +					continue

      
        
        243
        +				}

      
        
        244
        +				l.resolveOccurrence(rj, pf, match, childSrc, currentYear, append(stack, canon))

      
        
        245
        +			}

      
        
        246
        +

      
        
        247
        +		default:

      
        
        248
        +			rj.Items = append(rj.Items, ResolvedItem{Occurrence: pf, EntryIndex: i})

      
        
        249
        +		}

      
        
        250
        +

      
        
        251
        +		// track year directive for context propagation to child includes

      
        
        252
        +		if yd, ok := entry.(*ast.YearDirective); ok && yd.Year > 0 {

      
        
        253
        +			currentYear = yd.Year

      
        212
        254
         		}

      
        213
        255
         	}

      
        
        256
        +}

      
        214
        257
         

      
        215
        
        -	return pf, nil

      
        
        258
        +func resolveIncludePath(parentPath, incPattern string) (string, error) {

      
        
        259
        +	base := filepath.Dir(parentPath)

      
        
        260
        +	target := filepath.Join(base, incPattern)

      
        
        261
        +	// normalise to detect traversal

      
        
        262
        +	clean := filepath.Clean(target)

      
        
        263
        +	if !strings.HasPrefix(clean, filepath.Clean(base)+string(filepath.Separator)) &&

      
        
        264
        +		clean != filepath.Clean(base) &&

      
        
        265
        +		!filepath.IsAbs(incPattern) {

      
        
        266
        +		return "", fmt.Errorf("path traversal: %s", incPattern)

      
        
        267
        +	}

      
        
        268
        +	return clean, nil

      
        
        269
        +}

      
        
        270
        +

      
        
        271
        +func canonicalPath(path string) string {

      
        
        272
        +	abs, err := filepath.Abs(path)

      
        
        273
        +	if err != nil {

      
        
        274
        +		return filepath.Clean(path)

      
        
        275
        +	}

      
        
        276
        +	canonical, err := filepath.EvalSymlinks(abs)

      
        
        277
        +	if err != nil {

      
        
        278
        +		return filepath.Clean(abs)

      
        
        279
        +	}

      
        
        280
        +	return filepath.Clean(canonical)

      
        216
        281
         }

      
M journal/loader_test.go
···
        1
        1
         package journal

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"fmt"

      
        4
        5
         	"os"

      
        5
        6
         	"path/filepath"

      
        6
        7
         	"testing"

      
        7
        
        -)

      
        8
        8
         

      
        9
        
        -func TestLoader_LoadBytes(t *testing.T) {

      
        10
        
        -	t.Run("empty", func(t *testing.T) {

      
        11
        
        -		l := NewLoader()

      
        12
        
        -		pf, err := l.LoadBytes("empty.journal", []byte{})

      
        13
        
        -		if err != nil {

      
        14
        
        -			t.Fatalf("unexpected error: %v", err)

      
        15
        
        -		}

      
        16
        
        -		if pf.Ast == nil {

      
        17
        
        -			t.Fatal("expected non-nil AST")

      
        18
        
        -		}

      
        19
        
        -	})

      
        20
        
        -

      
        21
        
        -	t.Run("transaction", func(t *testing.T) {

      
        22
        
        -		l := NewLoader()

      
        23
        
        -		src := []byte("2024/01/01 groceries\n    expenses:food  $10\n    assets:checking\n")

      
        24
        
        -		pf, err := l.LoadBytes("t.journal", src)

      
        25
        
        -		if err != nil {

      
        26
        
        -			t.Fatalf("unexpected error: %v", err)

      
        27
        
        -		}

      
        28
        
        -		if len(pf.Errors) > 0 {

      
        29
        
        -			t.Fatalf("unexpected parse errors: %v", pf.Errors)

      
        30
        
        -		}

      
        31
        
        -		if len(pf.FileErrors) > 0 {

      
        32
        
        -			t.Fatalf("unexpected file errors: %v", pf.FileErrors)

      
        33
        
        -		}

      
        34
        
        -	})

      
        35
        
        -

      
        36
        
        -	t.Run("parse errors", func(t *testing.T) {

      
        37
        
        -		l := NewLoader()

      
        38
        
        -		src := []byte("@@@ garbage\n")

      
        39
        
        -		pf, err := l.LoadBytes("bad.journal", src)

      
        40
        
        -		if err != nil {

      
        41
        
        -			t.Fatalf("unexpected error: %v", err)

      
        42
        
        -		}

      
        43
        
        -		if len(pf.Errors) == 0 {

      
        44
        
        -			t.Fatal("expected parse errors")

      
        45
        
        -		}

      
        46
        
        -	})

      
        47
        
        -

      
        48
        
        -	t.Run("include not found", func(t *testing.T) {

      
        49
        
        -		l := NewLoader()

      
        50
        
        -		src := []byte("include nonexistent.journal\n")

      
        51
        
        -		pf, err := l.LoadBytes("parent.journal", src)

      
        52
        
        -		if err != nil {

      
        53
        
        -			t.Fatalf("unexpected error: %v", err)

      
        54
        
        -		}

      
        55
        
        -		if len(pf.FileErrors) == 0 {

      
        56
        
        -			t.Fatal("expected file errors for missing include")

      
        57
        
        -		}

      
        58
        
        -		if len(pf.Errors) > 0 {

      
        59
        
        -			t.Fatalf("expected no parse errors, got %d", len(pf.Errors))

      
        60
        
        -		}

      
        61
        
        -		for _, fe := range pf.FileErrors {

      
        62
        
        -			if fe.Path == "" {

      
        63
        
        -				t.Error("expected non-empty path in FileError")

      
        64
        
        -			}

      
        65
        
        -			if fe.Message == "" {

      
        66
        
        -				t.Error("expected non-empty message in FileError")

      
        67
        
        -			}

      
        68
        
        -		}

      
        69
        
        -	})

      
        70
        
        -

      
        71
        
        -	t.Run("self include dedup", func(t *testing.T) {

      
        72
        
        -		l := NewLoader()

      
        73
        
        -		src := []byte("include self.journal\n2024/01/01 t\n  a  $1\n")

      
        74
        
        -		pf, err := l.LoadBytes("self.journal", src)

      
        75
        
        -		if err != nil {

      
        76
        
        -			t.Fatalf("unexpected error: %v", err)

      
        77
        
        -		}

      
        78
        
        -		// self-include is deduped (file already stored before includes resolved)

      
        79
        
        -		if len(pf.Includes) != 0 {

      
        80
        
        -			t.Fatalf("expected 0 includes (deduped), got %d", len(pf.Includes))

      
        81
        
        -		}

      
        82
        
        -	})

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

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

      
        
        11
        +)

      
        83
        12
         

      
        84
        
        -	t.Run("dedup", func(t *testing.T) {

      
        85
        
        -		l := NewLoader()

      
        86
        
        -		src := []byte("2024/01/01 t\n  a  $1\n")

      
        87
        
        -		pf1, err := l.LoadBytes("same.journal", src)

      
        88
        
        -		if err != nil {

      
        89
        
        -			t.Fatalf("unexpected error: %v", err)

      
        90
        
        -		}

      
        91
        
        -		pf2, err := l.LoadBytes("same.journal", src)

      
        92
        
        -		if err != nil {

      
        93
        
        -			t.Fatalf("unexpected error: %v", err)

      
        94
        
        -		}

      
        95
        
        -		if pf1 != pf2 {

      
        96
        
        -			t.Fatal("expected same pointer for deduplicated load")

      
        97
        
        -		}

      
        98
        
        -		// different path, same content — should NOT dedup

      
        99
        
        -		pf3, err := l.LoadBytes("other.journal", src)

      
        100
        
        -		if err != nil {

      
        101
        
        -			t.Fatalf("unexpected error: %v", err)

      
        102
        
        -		}

      
        103
        
        -		if pf1 == pf3 {

      
        104
        
        -			t.Fatal("expected different pointers for different paths")

      
        105
        
        -		}

      
        106
        
        -	})

      
        
        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
        +	}

      
        107
        22
         }

      
        108
        23
         

      
        109
        
        -func TestLoader_Load(t *testing.T) {

      
        110
        
        -	dir := t.TempDir()

      
        111
        
        -	mainPath := filepath.Join(dir, "main.journal")

      
        112
        
        -	if err := os.WriteFile(mainPath, []byte("2024/01/01 t\n  a  $1\n"), 0o644); err != nil {

      
        113
        
        -		t.Fatalf("writing temp file: %v", err)

      
        114
        
        -	}

      
        
        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

      
        115
        30
         

      
        116
        
        -	l := NewLoader()

      
        117
        
        -	pf, err := l.Load(mainPath)

      
        118
        
        -	if err != nil {

      
        119
        
        -		t.Fatalf("unexpected error: %v", err)

      
        
        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))

      
        120
        40
         	}

      
        121
        
        -	if pf.Path != mainPath {

      
        122
        
        -		t.Fatalf("expected path %q, got %q", mainPath, pf.Path)

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

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

      
        123
        43
         	}

      
        124
        
        -	if len(pf.Errors) > 0 {

      
        125
        
        -		t.Fatalf("unexpected errors: %v", pf.Errors)

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

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

      
        126
        46
         	}

      
        
        47
        +}

      
        127
        48
         

      
        128
        
        -	t.Run("reuse on repeated load", func(t *testing.T) {

      
        129
        
        -		pf2, err := l.Load(mainPath)

      
        130
        
        -		if err != nil {

      
        131
        
        -			t.Fatalf("unexpected error: %v", err)

      
        132
        
        -		}

      
        133
        
        -		if pf != pf2 {

      
        134
        
        -			t.Fatal("expected same pointer on repeated load")

      
        135
        
        -		}

      
        136
        
        -	})

      
        
        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

      
        137
        57
         

      
        138
        
        -	t.Run("file not found", func(t *testing.T) {

      
        139
        
        -		_, err := l.Load(filepath.Join(dir, "nonexistent.journal"))

      
        140
        
        -		if err == nil {

      
        141
        
        -			t.Fatal("expected error for nonexistent file")

      
        
        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

      
        142
        67
         		}

      
        143
        
        -	})

      
        
        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)

      
        
        76
        +	}

      
        144
        77
         }

      
        145
        78
         

      
        146
        
        -func TestLoader_Load_withInclude(t *testing.T) {

      
        147
        
        -	dir := t.TempDir()

      
        
        79
        +func TestLoader_Resolve_cycleDetection(t *testing.T) {

      
        
        80
        +	rj := resolveTxtar(t, "a.journal", `

      
        
        81
        +-- a.journal --

      
        
        82
        +include b.journal

      
        148
        83
         

      
        149
        
        -	child := []byte("account expenses:food\n")

      
        150
        
        -	if err := os.WriteFile(filepath.Join(dir, "child.journal"), child, 0o644); err != nil {

      
        151
        
        -		t.Fatalf("writing child: %v", err)

      
        
        84
        +-- b.journal --

      
        
        85
        +include a.journal

      
        
        86
        +`)

      
        
        87
        +	if len(rj.FileErrors()) == 0 {

      
        
        88
        +		t.Fatal("expected cycle error")

      
        152
        89
         	}

      
        
        90
        +}

      
        153
        91
         

      
        154
        
        -	parent := []byte("include child.journal\n")

      
        155
        
        -	if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil {

      
        156
        
        -		t.Fatalf("writing parent: %v", err)

      
        157
        
        -	}

      
        
        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

      
        158
        97
         

      
        159
        
        -	l := NewLoader()

      
        160
        
        -	pf, err := l.Load(filepath.Join(dir, "parent.journal"))

      
        161
        
        -	if err != nil {

      
        162
        
        -		t.Fatalf("unexpected error: %v", err)

      
        163
        
        -	}

      
        164
        
        -	if len(pf.FileErrors) > 0 {

      
        165
        
        -		t.Fatalf("unexpected file errors: %v", pf.FileErrors)

      
        
        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))

      
        166
        103
         	}

      
        167
        
        -	if len(pf.Includes) != 1 {

      
        168
        
        -		t.Fatalf("expected 1 include, got %d", len(pf.Includes))

      
        
        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"]))

      
        169
        107
         	}

      
        170
        
        -	included := pf.Includes[0]

      
        171
        
        -	if included.Path != filepath.Join(dir, "child.journal") {

      
        172
        
        -		t.Fatalf("expected child path, got %q", included.Path)

      
        
        108
        +}

      
        
        109
        +

      
        
        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")

      
        173
        114
         	}

      
        174
        115
         }

      
        175
        116
         

      
        176
        
        -func TestLoader_Load_withGlobInclude(t *testing.T) {

      
        
        117
        +func TestLoader_ContentCache(t *testing.T) {

      
        177
        118
         	dir := t.TempDir()

      
        178
        
        -	sub := filepath.Join(dir, "data")

      
        179
        
        -	os.MkdirAll(sub, 0o755)

      
        
        119
        +	fpath := filepath.Join(dir, "a.journal")

      
        180
        120
         

      
        181
        
        -	for _, name := range []string{"a.journal", "b.journal", "c.journal"} {

      
        182
        
        -		if err := os.WriteFile(filepath.Join(sub, name), []byte("account expenses:"+name[:1]+"\n"), 0o644); err != nil {

      
        183
        
        -			t.Fatalf("writing %s: %v", name, err)

      
        184
        
        -		}

      
        185
        
        -	}

      
        
        121
        +	os.WriteFile(fpath, []byte("2024/01/15 t\n  a  $1\n"), 0o644)

      
        
        122
        +	l := NewLoader()

      
        186
        123
         

      
        187
        
        -	parent := []byte("include data/*.journal\n")

      
        188
        
        -	if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil {

      
        189
        
        -		t.Fatalf("writing parent: %v", err)

      
        
        124
        +	// first resolve populates cache.

      
        
        125
        +	rj1 := mustResolve(t, l, fpath)

      
        
        126
        +	date1 := txDate(rj1)

      
        
        127
        +

      
        
        128
        +	// modify file, resolve again - should return cached date.

      
        
        129
        +	os.WriteFile(fpath, []byte("2024/06/01 t\n  a  $1\n"), 0o644)

      
        
        130
        +	rj2 := mustResolve(t, l, fpath)

      
        
        131
        +	if txDate(rj2) != date1 {

      
        
        132
        +		t.Fatal("expected cached date before invalidation")

      
        190
        133
         	}

      
        191
        134
         

      
        192
        
        -	l := NewLoader()

      
        193
        
        -	pf, err := l.Load(filepath.Join(dir, "parent.journal"))

      
        194
        
        -	if err != nil {

      
        195
        
        -		t.Fatalf("unexpected error: %v", err)

      
        196
        
        -	}

      
        197
        
        -	if len(pf.FileErrors) > 0 {

      
        198
        
        -		t.Fatalf("unexpected file errors: %v", pf.FileErrors)

      
        199
        
        -	}

      
        200
        
        -	if len(pf.Includes) != 3 {

      
        201
        
        -		t.Fatalf("expected 3 includes, got %d", len(pf.Includes))

      
        
        135
        +	// invalidate and resolve again - should see new date.

      
        
        136
        +	l.InvalidateFile(fpath)

      
        
        137
        +	rj3 := mustResolve(t, l, fpath)

      
        
        138
        +	if txDate(rj3) != "2024-06-01" {

      
        
        139
        +		t.Fatalf("expected 2024-06-01 after invalidation, got %s", txDate(rj3))

      
        202
        140
         	}

      
        203
        141
         }

      
        204
        142
         

      
        205
        
        -func TestLoader_Load_includeNotFound(t *testing.T) {

      
        
        143
        +func TestLoader_ContentCache_LineEndings(t *testing.T) {

      
        206
        144
         	dir := t.TempDir()

      
        
        145
        +	fpath := filepath.Join(dir, "crlf.journal")

      
        
        146
        +	os.WriteFile(fpath, []byte("2024/01/01 t\r\n  a  $1\r\n"), 0o644)

      
        207
        147
         

      
        208
        
        -	parent := []byte("include data/*.journal\n")

      
        209
        
        -	if err := os.WriteFile(filepath.Join(dir, "parent.journal"), parent, 0o644); err != nil {

      
        210
        
        -		t.Fatalf("writing parent: %v", err)

      
        
        148
        +	rj, err := NewLoader().Resolve(fpath)

      
        
        149
        +	if err != nil || len(rj.Occurrences[0].Errors) > 0 {

      
        
        150
        +		t.Fatal("CRLF file should parse without errors")

      
        211
        151
         	}

      
        
        152
        +}

      
        212
        153
         

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

      
        213
        155
         	l := NewLoader()

      
        214
        
        -	pf, err := l.Load(filepath.Join(dir, "parent.journal"))

      
        215
        
        -	if err != nil {

      
        216
        
        -		t.Fatalf("unexpected error: %v", err)

      
        217
        
        -	}

      
        218
        
        -	if len(pf.FileErrors) != 1 {

      
        219
        
        -		t.Fatalf("expected 1 file error, got %d", len(pf.FileErrors))

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

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

      
        220
        158
         	}

      
        221
        
        -	if len(pf.Errors) != 0 {

      
        222
        
        -		t.Fatalf("expected 0 parse errors, got %d", len(pf.Errors))

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

      
        
        160
        +	l.ClearCache()

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

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

      
        223
        163
         	}

      
        224
        164
         }

      
        225
        165
         

      
        226
        
        -func TestLoader_Load_cycleDedup(t *testing.T) {

      
        227
        
        -	dir := t.TempDir()

      
        
        166
        +// helpers

      
        228
        167
         

      
        229
        
        -	aPath := filepath.Join(dir, "a.journal")

      
        230
        
        -	bPath := filepath.Join(dir, "b.journal")

      
        231
        
        -

      
        232
        
        -	if err := os.WriteFile(aPath, []byte("include b.journal\n"), 0o644); err != nil {

      
        233
        
        -		t.Fatalf("writing a: %v", err)

      
        
        168
        +func resolveTxtar(t *testing.T, rootFile, archive string) *ResolvedJournal {

      
        
        169
        +	t.Helper()

      
        
        170
        +	a := txtar.Parse([]byte(archive))

      
        
        171
        +	fsys, err := txtar.FS(a)

      
        
        172
        +	if err != nil {

      
        
        173
        +		t.Fatal(err)

      
        234
        174
         	}

      
        235
        
        -	if err := os.WriteFile(bPath, []byte("include a.journal\n"), 0o644); err != nil {

      
        236
        
        -		t.Fatalf("writing b: %v", err)

      
        237
        
        -	}

      
        238
        
        -

      
        239
        175
         	l := NewLoader()

      
        240
        
        -	pf, err := l.Load(aPath)

      
        
        176
        +	rj, err := l.ResolveFS(fsys, rootFile)

      
        241
        177
         	if err != nil {

      
        242
        
        -		t.Fatalf("unexpected error: %v", err)

      
        243
        
        -	}

      
        244
        
        -	// circular A→B→A: A is deduped (already stored before includes resolved)

      
        245
        
        -	if len(pf.Includes) != 1 {

      
        246
        
        -		t.Fatalf("expected 1 include, got %d", len(pf.Includes))

      
        
        178
        +		t.Fatal(err)

      
        247
        179
         	}

      
        248
        
        -	if pf.Includes[0].Path != bPath {

      
        249
        
        -		t.Fatalf("expected include path %q, got %q", bPath, pf.Includes[0].Path)

      
        250
        
        -	}

      
        251
        
        -	// B's include of A should resolve to the same pf (dedup)

      
        252
        
        -	if pf.Includes[0].Includes[0] != pf {

      
        253
        
        -		t.Fatal("expected circular dedup: B's include of A should point to original A")

      
        254
        
        -	}

      
        
        180
        +	return rj

      
        255
        181
         }

      
        256
        182
         

      
        257
        
        -func TestLoader_Ordered(t *testing.T) {

      
        258
        
        -	dir := t.TempDir()

      
        259
        
        -

      
        260
        
        -	leaf := []byte("2024/01/01 t\n  a  $1\n")

      
        261
        
        -	if err := os.WriteFile(filepath.Join(dir, "leaf.journal"), leaf, 0o644); err != nil {

      
        262
        
        -		t.Fatalf("writing leaf: %v", err)

      
        263
        
        -	}

      
        264
        
        -

      
        265
        
        -	middle := []byte("include leaf.journal\n")

      
        266
        
        -	if err := os.WriteFile(filepath.Join(dir, "middle.journal"), middle, 0o644); err != nil {

      
        267
        
        -		t.Fatalf("writing middle: %v", err)

      
        268
        
        -	}

      
        269
        
        -

      
        270
        
        -	root := []byte("include middle.journal\n")

      
        271
        
        -	if err := os.WriteFile(filepath.Join(dir, "root.journal"), root, 0o644); err != nil {

      
        272
        
        -		t.Fatalf("writing root: %v", err)

      
        273
        
        -	}

      
        274
        
        -

      
        275
        
        -	l := NewLoader()

      
        276
        
        -	pf, err := l.Load(filepath.Join(dir, "root.journal"))

      
        
        183
        +func mustResolve(t *testing.T, l *Loader, fpath string) *ResolvedJournal {

      
        
        184
        +	t.Helper()

      
        
        185
        +	rj, err := l.Resolve(fpath)

      
        277
        186
         	if err != nil {

      
        278
        
        -		t.Fatalf("unexpected error: %v", err)

      
        279
        
        -	}

      
        280
        
        -	if len(pf.FileErrors) > 0 {

      
        281
        
        -		t.Fatalf("unexpected file errors: %v", pf.FileErrors)

      
        
        187
        +		t.Fatal(err)

      
        282
        188
         	}

      
        283
        
        -

      
        284
        
        -	ordered := l.Ordered()

      
        285
        
        -	names := make([]string, len(ordered))

      
        286
        
        -	for i, f := range ordered {

      
        287
        
        -		names[i] = filepath.Base(f.Path)

      
        288
        
        -	}

      
        289
        
        -

      
        290
        
        -	// leaf before middle before root — includes before includer

      
        291
        
        -	if len(names) != 3 {

      
        292
        
        -		t.Fatalf("expected 3 ordered files, got %d: %v", len(names), names)

      
        293
        
        -	}

      
        294
        
        -	if names[0] != "leaf.journal" {

      
        295
        
        -		t.Fatalf("expected leaf first, got %q", names[0])

      
        296
        
        -	}

      
        297
        
        -	if names[1] != "middle.journal" {

      
        298
        
        -		t.Fatalf("expected middle second, got %q", names[1])

      
        299
        
        -	}

      
        300
        
        -	if names[2] != "root.journal" {

      
        301
        
        -		t.Fatalf("expected root last, got %q", names[2])

      
        302
        
        -	}

      
        
        189
        +	return rj

      
        303
        190
         }

      
        304
        191
         

      
        305
        
        -func TestLoader_Load_fileErrorsSeparateFromParseErrors(t *testing.T) {

      
        306
        
        -	l := NewLoader()

      
        307
        
        -	src := []byte("@@@ garbage\ninclude nonexistent.journal\n")

      
        308
        
        -	pf, err := l.LoadBytes("mixed.journal", src)

      
        309
        
        -	if err != nil {

      
        310
        
        -		t.Fatalf("unexpected error: %v", err)

      
        311
        
        -	}

      
        312
        
        -	if len(pf.Errors) == 0 {

      
        313
        
        -		t.Fatal("expected parse errors")

      
        314
        
        -	}

      
        315
        
        -	if len(pf.FileErrors) == 0 {

      
        316
        
        -		t.Fatal("expected file errors")

      
        317
        
        -	}

      
        
        192
        +func txDate(rj *ResolvedJournal) string {

      
        
        193
        +	tx := rj.Occurrences[0].Ast.Entries[0].(*ast.Transaction)

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

      
        318
        195
         }

      
M journal/parser/benchmark_test.go
···
        5
        5
         	"testing"

      
        6
        6
         

      
        7
        7
         	"olexsmir.xyz/clerk/journal/lexer"

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

      
        9
        8
         )

      
        10
        9
         

      
        11
        
        -func loadTestdata(path string) []byte {

      
        12
        
        -	src, err := os.ReadFile(path)

      
        
        10
        +func BenchmarkParser(b *testing.B) {

      
        
        11
        +	b.Run("basic", bench("../../journal/testdata/journals/basic.journal"))

      
        
        12
        +	b.Run("personal", bench("../../journal/testdata/journals/actual-personal.journal"))

      
        
        13
        +	b.Run("sample", bench("../../journal/testdata/journals/actual-sample.journal"))

      
        
        14
        +	b.Run("wow", bench("../../journal/testdata/journals/actual-ledger-input-wow.dat"))

      
        
        15
        +	b.Run("1k txns", bench("../../journal/testdata/journals/actual-1ktxns-100accts.journal"))

      
        
        16
        +}

      
        
        17
        +

      
        
        18
        +func bench(fpath string) func(b *testing.B) {

      
        
        19
        +	src, err := os.ReadFile(fpath)

      
        13
        20
         	if err != nil {

      
        14
        21
         		panic("loadTestdata: " + err.Error())

      
        15
        22
         	}

      
        16
        
        -	return src

      
        17
        
        -}

      
        18
        23
         

      
        19
        
        -var (

      
        20
        
        -	basicSrc     = loadTestdata("../../journal/testdata/journals/basic.journal")

      
        21
        
        -	personalSrc  = loadTestdata("../../journal/testdata/journals/actual-personal.journal")

      
        22
        
        -	sampleSrc    = loadTestdata("../../journal/testdata/journals/actual-sample.journal")

      
        23
        
        -	standardSrc  = loadTestdata("../../journal/testdata/journals/actual-ledger-input-standard.dat")

      
        24
        
        -	wowSrc       = loadTestdata("../../journal/testdata/journals/actual-ledger-input-wow.dat")

      
        25
        
        -	benchmarkSrc = loadTestdata("../../journal/testdata/journals/actual-1ktxns-100accts.journal")

      
        26
        
        -)

      
        27
        
        -

      
        28
        
        -func BenchmarkLexer_Basic(b *testing.B) {

      
        29
        
        -	for b.Loop() {

      
        30
        
        -		l := lexer.New("bench", basicSrc)

      
        31
        
        -		for l.Next().Type != token.EOF {

      
        
        24
        +	return func(b *testing.B) {

      
        
        25
        +		b.ResetTimer()

      
        
        26
        +		b.ReportAllocs()

      
        
        27
        +		for b.Loop() {

      
        
        28
        +			l := lexer.New("bench", src)

      
        
        29
        +			New(l).ParseJournal()

      
        32
        30
         		}

      
        33
        31
         	}

      
        34
        32
         }

      
        35
        
        -

      
        36
        
        -func BenchmarkLexer_Personal(b *testing.B) {

      
        37
        
        -	for b.Loop() {

      
        38
        
        -		l := lexer.New("bench", personalSrc)

      
        39
        
        -		for l.Next().Type != token.EOF {

      
        40
        
        -		}

      
        41
        
        -	}

      
        42
        
        -}

      
        43
        
        -

      
        44
        
        -func BenchmarkLexer_Sample(b *testing.B) {

      
        45
        
        -	for b.Loop() {

      
        46
        
        -		l := lexer.New("bench", sampleSrc)

      
        47
        
        -		for l.Next().Type != token.EOF {

      
        48
        
        -		}

      
        49
        
        -	}

      
        50
        
        -}

      
        51
        
        -

      
        52
        
        -func BenchmarkLexer_Standard(b *testing.B) {

      
        53
        
        -	for b.Loop() {

      
        54
        
        -		l := lexer.New("bench", standardSrc)

      
        55
        
        -		for l.Next().Type != token.EOF {

      
        56
        
        -		}

      
        57
        
        -	}

      
        58
        
        -}

      
        59
        
        -

      
        60
        
        -func BenchmarkLexer_Wow(b *testing.B) {

      
        61
        
        -	for b.Loop() {

      
        62
        
        -		l := lexer.New("bench", wowSrc)

      
        63
        
        -		for l.Next().Type != token.EOF {

      
        64
        
        -		}

      
        65
        
        -	}

      
        66
        
        -}

      
        67
        
        -

      
        68
        
        -func BenchmarkLexer_1kTxns(b *testing.B) {

      
        69
        
        -	for b.Loop() {

      
        70
        
        -		l := lexer.New("bench", benchmarkSrc)

      
        71
        
        -		for l.Next().Type != token.EOF {

      
        72
        
        -		}

      
        73
        
        -	}

      
        74
        
        -}

      
        75
        
        -

      
        76
        
        -func BenchmarkParser_Basic(b *testing.B) {

      
        77
        
        -	for b.Loop() {

      
        78
        
        -		l := lexer.New("bench", basicSrc)

      
        79
        
        -		New(l).ParseJournal()

      
        80
        
        -	}

      
        81
        
        -}

      
        82
        
        -

      
        83
        
        -func BenchmarkParser_Personal(b *testing.B) {

      
        84
        
        -	for b.Loop() {

      
        85
        
        -		l := lexer.New("bench", personalSrc)

      
        86
        
        -		New(l).ParseJournal()

      
        87
        
        -	}

      
        88
        
        -}

      
        89
        
        -

      
        90
        
        -func BenchmarkParser_Sample(b *testing.B) {

      
        91
        
        -	for b.Loop() {

      
        92
        
        -		l := lexer.New("bench", sampleSrc)

      
        93
        
        -		New(l).ParseJournal()

      
        94
        
        -	}

      
        95
        
        -}

      
        96
        
        -

      
        97
        
        -func BenchmarkParser_Standard(b *testing.B) {

      
        98
        
        -	for b.Loop() {

      
        99
        
        -		l := lexer.New("bench", standardSrc)

      
        100
        
        -		New(l).ParseJournal()

      
        101
        
        -	}

      
        102
        
        -}

      
        103
        
        -

      
        104
        
        -func BenchmarkParser_Wow(b *testing.B) {

      
        105
        
        -	for b.Loop() {

      
        106
        
        -		l := lexer.New("bench", wowSrc)

      
        107
        
        -		New(l).ParseJournal()

      
        108
        
        -	}

      
        109
        
        -}

      
        110
        
        -

      
        111
        
        -func BenchmarkParser_1kTxns(b *testing.B) {

      
        112
        
        -	for b.Loop() {

      
        113
        
        -		l := lexer.New("bench", benchmarkSrc)

      
        114
        
        -		New(l).ParseJournal()

      
        115
        
        -	}

      
        116
        
        -}

      
        117
        
        -

      
        118
        
        -func BenchmarkParser_1kTxns_Allocs(b *testing.B) {

      
        119
        
        -	b.ReportAllocs()

      
        120
        
        -	for b.Loop() {

      
        121
        
        -		l := lexer.New("bench", benchmarkSrc)

      
        122
        
        -		New(l).ParseJournal()

      
        123
        
        -	}

      
        124
        
        -}

      
        125
        
        -

      
        126
        
        -func BenchmarkParser_Parallel_1kTxns(b *testing.B) {

      
        127
        
        -	b.RunParallel(func(pb *testing.PB) {

      
        128
        
        -		for pb.Next() {

      
        129
        
        -			l := lexer.New("bench", benchmarkSrc)

      
        130
        
        -			New(l).ParseJournal()

      
        131
        
        -		}

      
        132
        
        -	})

      
        133
        
        -}

      
M journal/parser/parser.go
···
        16
        16
         	errors []*ast.ParseError

      
        17
        17
         	cur    token.Token

      
        18
        18
         	peek   token.Token

      
        
        19
        +

      
        
        20
        +	defaultYear int // set by year directive, used for short date inference

      
        19
        21
         }

      
        20
        22
         

      
        21
        23
         func New(lex *lexer.Lexer) *Parser {

      
        22
        24
         	p := &Parser{lexer: lex}

      
        
        25
        +	p.advance() // populate .peek

      
        
        26
        +	p.advance() // populate .cur

      
        
        27
        +	return p

      
        
        28
        +}

      
        
        29
        +

      
        
        30
        +func NewWithYear(lex *lexer.Lexer, year int) *Parser {

      
        
        31
        +	p := &Parser{lexer: lex, defaultYear: year}

      
        23
        32
         	p.advance() // populate .peek

      
        24
        33
         	p.advance() // populate .cur

      
        25
        34
         	return p

      ···
        519
        528
         

      
        520
        529
         	if p.got(token.INT) {

      
        521
        530
         		year.Year, _ = strconv.Atoi(p.cur.Literal)

      
        
        531
        +		p.defaultYear = year.Year

      
        522
        532
         		p.advance()

      
        523
        533
         	} else {

      
        524
        534
         		p.errorf("expected year, got %s", p.cur.Type)

      ···
        528
        538
         	year.Comment = p.parseOptInlineComment()

      
        529
        539
         	p.expectNewline()

      
        530
        540
         	year.Span = p.span(s)

      
        
        541
        +

      
        531
        542
         	return year

      
        532
        543
         }

      
        533
        544
         

      ···
        1100
        1111
         			p.errorf("invalid day %d in %q", day, lit)

      
        1101
        1112
         			return ast.Date{Span: p.span(s)}

      
        1102
        1113
         		}

      
        1103
        
        -		return ast.Date{Month: month, Day: day, Sep: sep, Span: p.span(s)}

      
        
        1114
        +		return ast.Date{Year: p.defaultYear, Month: month, Day: day, Sep: sep, Span: p.span(s)}

      
        1104
        1115
         	}

      
        1105
        1116
         

      
        1106
        1117
         	if len(parts) != 3 {

      
M journal/printer/printer_test.go
···
        14
        14
         	for _, tname := range tests {

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

      
        16
        16
         			a := golden.Read(t, tname)

      
        17
        
        -			pf, err := journal.NewLoader().LoadBytes(tname+".journal", a.Get("input"))

      
        18
        
        -			if err != nil {

      
        19
        
        -				t.Fatal(err)

      
        20
        
        -			}

      
        
        17
        +			rj := journal.NewLoader().ResolveBytes(tname+".journal", a.Get("input"))

      
        
        18
        +			pf := rj.Occurrences[0]

      
        21
        19
         			var b strings.Builder

      
        22
        20
         			if err := DefaultConfig.Fprint(&b, pf.Ast); err != nil {

      
        23
        21
         				t.Fatal(err)

      ···
        33
        31
         		b.Run(tname, func(b *testing.B) {

      
        34
        32
         			b.ReportAllocs()

      
        35
        33
         			inp := golden.Read(b, tname).Get("input")

      
        36
        
        -			pf, err := journal.NewLoader().LoadBytes(tname+".journal", inp)

      
        37
        
        -			if err != nil {

      
        38
        
        -				b.Fatal(err)

      
        39
        
        -			}

      
        
        34
        +			rj := journal.NewLoader().ResolveBytes(tname+".journal", inp)

      
        
        35
        +			pf := rj.Occurrences[0]

      
        40
        36
         			b.ResetTimer()

      
        41
        37
         			for i := 0; i < b.N; i++ {

      
        42
        38
         				var buf strings.Builder

      ···
        61
        57
         	for tname, tt := range testsWithConfig {

      
        62
        58
         		t.Run(tname, func(t *testing.T) {

      
        63
        59
         			a := golden.Read(t, tname)

      
        64
        
        -			pf, err := journal.NewLoader().LoadBytes(tname+".journal", a.Get("input"))

      
        65
        
        -			if err != nil {

      
        66
        
        -				t.Fatal(err)

      
        67
        
        -			}

      
        
        60
        +			rj := journal.NewLoader().ResolveBytes(tname+".journal", a.Get("input"))

      
        
        61
        +			pf := rj.Occurrences[0]

      
        68
        62
         			var b strings.Builder

      
        69
        63
         			if err := tt.Fprint(&b, pf.Ast); err != nil {

      
        70
        64
         				t.Fatal(err)

      ···
        77
        71
         

      
        78
        72
         func BenchmarkPrinter_Config(b *testing.B) {

      
        79
        73
         	inp := golden.Read(b, "entries").Get("input")

      
        80
        
        -	pf, err := journal.NewLoader().LoadBytes("entries.journal", inp)

      
        81
        
        -	if err != nil {

      
        82
        
        -		b.Fatal(err)

      
        83
        
        -	}

      
        
        74
        +	rj := journal.NewLoader().ResolveBytes("entries.journal", inp)

      
        
        75
        +	pf := rj.Occurrences[0]

      
        84
        76
         	for name, cfg := range testsWithConfig {

      
        85
        77
         		b.Run(name, func(b *testing.B) {

      
        86
        78
         			b.ReportAllocs()

      
M journal/tags/tag.go
···
        53
        53
         }

      
        54
        54
         

      
        55
        55
         type Tagger struct {

      
        56
        
        -	loader *journal.Loader

      
        
        56
        +	rj     *journal.ResolvedJournal

      
        57
        57
         	relDir string

      
        58
        58
         

      
        59
        59
         	candidates []candidate

      
        60
        60
         }

      
        61
        61
         

      
        62
        
        -// New creates a [Tagger]. If relDir is not empty, file paths in the tag output are made relative to relDir.

      
        63
        
        -func New(loader *journal.Loader, relDir string) *Tagger {

      
        64
        
        -	return &Tagger{loader: loader, relDir: relDir}

      
        
        62
        +// New creates a [Tagger] from a resolved journal. If relDir is not empty,

      
        
        63
        +// file paths in the tag output are made relative to relDir.

      
        
        64
        +func New(rj *journal.ResolvedJournal, relDir string) *Tagger {

      
        
        65
        +	return &Tagger{rj: rj, relDir: relDir}

      
        65
        66
         }

      
        66
        67
         

      
        67
        68
         // Write generate tags file and write it in tags format.

      ···
        80
        81
         func (t *Tagger) collect() []tags.Entry {

      
        81
        82
         	t.candidates = nil

      
        82
        83
         

      
        83
        
        -	for _, pf := range t.loader.Ordered() {

      
        
        84
        +	for _, item := range t.rj.Items {

      
        
        85
        +		if item.IsInclude {

      
        
        86
        +			continue

      
        
        87
        +		}

      
        
        88
        +		pf := item.Occurrence

      
        84
        89
         		filePath := pf.Path

      
        85
        90
         		if t.relDir != "" {

      
        86
        91
         			filePath = relativePath(filePath, t.relDir)

      
        87
        92
         		}

      
        88
        
        -		for _, entry := range pf.Ast.Entries {

      
        89
        
        -			t.collectFromEntry(pf, filePath, entry)

      
        90
        
        -		}

      
        
        93
        +		t.collectFromEntry(pf, filePath, pf.Ast.Entries[item.EntryIndex])

      
        91
        94
         	}

      
        92
        95
         

      
        93
        96
         	// sort by priority

      
M journal/tags/tag_test.go
···
        19
        19
         				t.Fatal(err)

      
        20
        20
         			}

      
        21
        21
         			l := journal.NewLoader()

      
        22
        
        -			if _, err := l.LoadFS(fsys, "in.journal"); err != nil {

      
        
        22
        +			rj, err := l.ResolveFS(fsys, "in.journal")

      
        
        23
        +			if err != nil {

      
        23
        24
         				t.Fatalf("loading journal: %v", err)

      
        24
        25
         			}

      
        25
        
        -			tagger := New(l, "")

      
        
        26
        +			tagger := New(rj, "")

      
        26
        27
         			var buf bytes.Buffer

      
        27
        28
         			if err := tagger.Write(&buf); err != nil {

      
        28
        29
         				t.Fatal(err)