all repos

clerk @ 9369172

missing tooling for ledger/hledger
5 files changed, 147 insertions(+), 12 deletions(-)
linter/lsp: scope checks to include tree
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-07-11 12:58:15 +0300
Authored at: 2026-06-29 10:52:47 +0300
Change ID: psmrrlzoqxkqkwrzwzswuxsltwmkpwoz
Parent: 225a2d3
M internal/cli/cmd_lint.go
···
        43
        43
         		}

      
        44
        44
         	}

      
        45
        45
         

      
        46
        
        -	sema := semantic.Build(loader.Ordered())

      
        47
        
        -	reporter.Collect(lint.Run(sema))

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

      
        
        47
        +		reporter.Collect(lint.Run(semantic.Build(journal.CollectFiles(root))))

      
        
        48
        +	}

      
        48
        49
         

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

      
        50
        51
         		return fmt.Errorf("flushing report: %w", err)

      ···
        62
        63
         		return err

      
        63
        64
         	}

      
        64
        65
         

      
        65
        
        -	sema := semantic.Build(loader.Ordered())

      
        66
        
        -	reporter.Collect(lint.Run(sema))

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

      
        
        67
        +		reporter.Collect(lint.Run(semantic.Build(journal.CollectFiles(root))))

      
        
        68
        +	}

      
        67
        69
         

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

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

      
M internal/linter/linter_test.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"os"

      
        
        5
        +	"path/filepath"

      
        4
        6
         	"strings"

      
        5
        7
         	"testing"

      
        6
        8
         

      ···
        46
        48
         	}

      
        47
        49
         }

      
        48
        50
         

      
        
        51
        +func TestLinter_CrossFileScoping(t *testing.T) {

      
        
        52
        +	t.Run("undeclared across sibling trees", func(t *testing.T) {

      
        
        53
        +		loader := loadTestFiles(t, map[string]string{

      
        
        54
        +			"root_a.journal": "2026/01/01 * \"A\"\n  Expenses:Food  $10\n  Assets:Cash\n",

      
        
        55
        +			"root_b.journal": "account Expenses:Food\n",

      
        
        56
        +		})

      
        
        57
        +		finds := runLint(t, loader)

      
        
        58
        +

      
        
        59
        +		n := 0

      
        
        60
        +		for _, f := range finds {

      
        
        61
        +			if f.Code == "undeclared-account" {

      
        
        62
        +				n++

      
        
        63
        +			}

      
        
        64
        +		}

      
        
        65
        +		if n != 2 {

      
        
        66
        +			t.Fatalf("expected 2 undeclared (Expenses:Food + Assets:Cash in root_a), got %d", n)

      
        
        67
        +		}

      
        
        68
        +	})

      
        
        69
        +

      
        
        70
        +	t.Run("declaration in included file visible", func(t *testing.T) {

      
        
        71
        +		loader := loadTestFiles(t, map[string]string{

      
        
        72
        +			"child.journal":  "account Expenses:Food\n",

      
        
        73
        +			"parent.journal": "include child.journal\n\n2026/01/01 * \"Lunch\"\n  Expenses:Food  $10\n  Assets:Cash\n",

      
        
        74
        +		})

      
        
        75
        +		finds := runLint(t, loader)

      
        
        76
        +

      
        
        77
        +		n := 0

      
        
        78
        +		for _, f := range finds {

      
        
        79
        +			if f.Code == "undeclared-account" {

      
        
        80
        +				n++

      
        
        81
        +			}

      
        
        82
        +		}

      
        
        83
        +		if n != 1 {

      
        
        84
        +			t.Fatalf("expected 1 undeclared (Assets:Cash), got %d", n)

      
        
        85
        +		}

      
        
        86
        +	})

      
        
        87
        +

      
        
        88
        +	t.Run("duplicate across sibling trees not flagged", func(t *testing.T) {

      
        
        89
        +		loader := loadTestFiles(t, map[string]string{

      
        
        90
        +			"a.journal": "account Expenses:Food\n",

      
        
        91
        +			"b.journal": "account Expenses:Food\n",

      
        
        92
        +		})

      
        
        93
        +		finds := runLint(t, loader)

      
        
        94
        +

      
        
        95
        +		for _, f := range finds {

      
        
        96
        +			if f.Code == "duplicated-account" {

      
        
        97
        +				t.Fatalf("unexpected duplicate across trees: %v", f)

      
        
        98
        +			}

      
        
        99
        +		}

      
        
        100
        +	})

      
        
        101
        +}

      
        
        102
        +

      
        49
        103
         func BenchmarkLinter(b *testing.B) {

      
        50
        104
         	ldr := journal.NewLoader()

      
        51
        105
         	_, err := ldr.Load(

      ···
        64
        118
         		l.Run(ctx)

      
        65
        119
         	}

      
        66
        120
         }

      
        
        121
        +

      
        
        122
        +func loadTestFiles(t testing.TB, files map[string]string) *journal.Loader {

      
        
        123
        +	t.Helper()

      
        
        124
        +	dir := t.TempDir()

      
        
        125
        +	loader := journal.NewLoader()

      
        
        126
        +	for name, content := range files {

      
        
        127
        +		if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {

      
        
        128
        +			t.Fatal(err)

      
        
        129
        +		}

      
        
        130
        +		if _, err := loader.Load(filepath.Join(dir, name)); err != nil {

      
        
        131
        +			t.Fatal(err)

      
        
        132
        +		}

      
        
        133
        +	}

      
        
        134
        +	return loader

      
        
        135
        +}

      
        
        136
        +

      
        
        137
        +func runLint(t testing.TB, loader *journal.Loader) []Find {

      
        
        138
        +	t.Helper()

      
        
        139
        +	lint := NewLinter(Rules)

      
        
        140
        +	var finds []Find

      
        
        141
        +	for _, root := range loader.Roots() {

      
        
        142
        +		finds = append(finds, lint.Run(semantic.Build(journal.CollectFiles(root)))...)

      
        
        143
        +	}

      
        
        144
        +	return finds

      
        
        145
        +}

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

      
        3
        3
         import (

      
        4
        4
         	"context"

      
        
        5
        +	"fmt"

      
        5
        6
         	"time"

      
        6
        7
         

      
        7
        8
         	"go.lsp.dev/jsonrpc2"

      
        8
        9
         	"go.lsp.dev/protocol"

      
        9
        10
         	"go.lsp.dev/uri"

      
        
        11
        +

      
        10
        12
         	"olexsmir.xyz/clerk/internal/linter"

      
        11
        13
         	"olexsmir.xyz/clerk/journal"

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

      ···
        31
        33
         		}

      
        32
        34
         	}

      
        33
        35
         

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

      
        35
        
        -	if len(ordered) == 0 {

      
        
        36
        +	roots := s.loader.Roots()

      
        
        37
        +	if len(roots) == 0 {

      
        36
        38
         		s.logger.Debug("no files in workspace")

      
        37
        39
         		return

      
        38
        40
         	}

      
        39
        41
         

      
        40
        
        -	s.sema = semantic.Build(ordered) // TODO: add dynamic relaoder to sema

      
        41
        
        -	finds := s.linter.Run(s.sema)

      
        
        42
        +	var finds []linter.Find

      
        
        43
        +	for _, root := range roots {

      
        
        44
        +		finds = append(finds, s.linter.Run(semantic.Build(journal.CollectFiles(root)))...)

      
        
        45
        +	}

      
        
        46
        +	finds = dedupFinds(finds)

      
        42
        47
         

      
        43
        48
         	diagsByFile := groupFindsByFile(finds)

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

      
        
        49
        +	activeFiles := activeFileSet(s.openDocs, s.loader.Ordered())

      
        45
        50
         	for fpath := range activeFiles {

      
        46
        51
         		diags := diagsByFile[fpath]

      
        47
        52
         		if diags == nil { // TODO: not sure if it's needed

      ···
        55
        60
         		}

      
        56
        61
         	}

      
        57
        62
         

      
        58
        
        -	s.logger.Debug("workspace rebuild complete", "files", len(ordered), "findings", len(finds))

      
        
        63
        +	s.logger.Debug("workspace rebuild complete", "files", len(s.loader.Ordered()), "findings", len(finds))

      
        59
        64
         }

      
        60
        65
         

      
        61
        66
         func (s *server) scheduleWorkspaceRebuild(ctx context.Context) {

      ···
        121
        126
         		diags[file] = append(diags[file], findToDiagnostic(find))

      
        122
        127
         	}

      
        123
        128
         	return diags

      
        
        129
        +}

      
        
        130
        +

      
        
        131
        +func dedupFinds(finds []linter.Find) []linter.Find {

      
        
        132
        +	seen := make(map[string]bool)

      
        
        133
        +	dedup := make([]linter.Find, 0, len(finds))

      
        
        134
        +	for _, f := range finds {

      
        
        135
        +		s := f.Span.Start

      
        
        136
        +		key := fmt.Sprintf("%s:%d:%d:%s", s.File, s.Line, s.Col, f.Code)

      
        
        137
        +		if seen[key] {

      
        
        138
        +			continue

      
        
        139
        +		}

      
        
        140
        +		seen[key] = true

      
        
        141
        +		dedup = append(dedup, f)

      
        
        142
        +	}

      
        
        143
        +	return dedup

      
        124
        144
         }

      
        125
        145
         

      
        126
        146
         func findToDiagnostic(find linter.Find) protocol.Diagnostic {

      
M internal/lsp/server.go
···
        12
        12
         	"olexsmir.xyz/clerk/internal/linter"

      
        13
        13
         	"olexsmir.xyz/clerk/journal"

      
        14
        14
         	"olexsmir.xyz/clerk/journal/printer"

      
        15
        
        -	"olexsmir.xyz/clerk/journal/semantic"

      
        16
        15
         )

      
        17
        16
         

      
        18
        17
         const name = "clerk"

      ···
        34
        33
         	logger     *slog.Logger

      
        35
        34
         

      
        36
        35
         	loader    *journal.Loader

      
        37
        
        -	sema      *semantic.Context

      
        38
        36
         	linter    *linter.Linter

      
        39
        37
         	debouncer *time.Timer

      
        40
        38
         

      
M journal/loader.go
···
        46
        46
         	return l.loadBytes(abs, src, nil)

      
        47
        47
         }

      
        48
        48
         

      
        
        49
        +// Roots returns files not transitively included by any loaded file.

      
        
        50
        +func (l *Loader) Roots() []*ParsedFile {

      
        
        51
        +	included := make(map[string]bool)

      
        
        52
        +	for _, pf := range l.files {

      
        
        53
        +		for _, inc := range pf.Includes {

      
        
        54
        +			included[inc.Path] = true

      
        
        55
        +		}

      
        
        56
        +	}

      
        
        57
        +	var roots []*ParsedFile

      
        
        58
        +	for _, pf := range l.files {

      
        
        59
        +		if !included[pf.Path] {

      
        
        60
        +			roots = append(roots, pf)

      
        
        61
        +		}

      
        
        62
        +	}

      
        
        63
        +	return roots

      
        
        64
        +}

      
        
        65
        +

      
        
        66
        +// CollectFiles returns root and all its transitive includes.

      
        
        67
        +func CollectFiles(root *ParsedFile) []*ParsedFile {

      
        
        68
        +	var files []*ParsedFile

      
        
        69
        +	visited := make(map[*ParsedFile]bool)

      
        
        70
        +	var walk func(*ParsedFile)

      
        
        71
        +	walk = func(pf *ParsedFile) {

      
        
        72
        +		if visited[pf] {

      
        
        73
        +			return

      
        
        74
        +		}

      
        
        75
        +		visited[pf] = true

      
        
        76
        +		files = append(files, pf)

      
        
        77
        +		for _, inc := range pf.Includes {

      
        
        78
        +			walk(inc)

      
        
        79
        +		}

      
        
        80
        +	}

      
        
        81
        +	walk(root)

      
        
        82
        +	return files

      
        
        83
        +}

      
        
        84
        +

      
        49
        85
         // Ordered returns all files in dependency order (included before includer)

      
        50
        86
         func (l *Loader) Ordered() []*ParsedFile {

      
        51
        87
         	visited := make(map[string]bool)