all repos

clerk @ lsp

missing tooling for ledger/hledger
master...lsp 6 ahead, 8 behind
Merge base: 45890c9, Base: 1f3a173, Head: 6106d58

Commits in lsp not in master

17 files changed, 947 insertions(+), 14 deletions(-)
Message Author Hash Age
lsp: completion Oleksandr Smirnov olexsmir@gmail.com Oleksandr Smirnov
olexsmir@gmail.com
6106d58 19 days ago 2026-07-11 12:58:15 +0300
linter/lsp: scope checks to include tree Oleksandr Smirnov olexsmir@gmail.com Oleksandr Smirnov
olexsmir@gmail.com
9369172 19 days ago 2026-07-11 12:58:15 +0300
lsp: diagnostics Oleksandr Smirnov olexsmir@gmail.com Oleksandr Smirnov
olexsmir@gmail.com
225a2d3 19 days ago 2026-07-11 12:58:15 +0300
lsp: format Oleksandr Smirnov olexsmir@gmail.com Oleksandr Smirnov
olexsmir@gmail.com
8e87a98 19 days ago 2026-07-11 12:58:15 +0300
lsp server Oleksandr Smirnov olexsmir@gmail.com Oleksandr Smirnov
olexsmir@gmail.com
f9a2588 19 days ago 2026-07-11 12:58:15 +0300
add AGENTS.md Oleksandr Smirnov olexsmir@gmail.com Oleksandr Smirnov
olexsmir@gmail.com
e62c721 19 days ago 2026-07-11 12:51:53 +0300
A AGENTS.md
···
        
        1
        +# clerk — plain-text accounting toolchain

      
        
        2
        +

      
        
        3
        +Go toolkit for ledger/hledger journal files: parsing, linting, formatting, LSP, tags.

      
        
        4
        +

      
        
        5
        +## Commands

      
        
        6
        +```bash

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

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

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

      
        
        10
        +```

      
        
        11
        +

      
        
        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)

      
        
        15
        +

      
        
        16
        +## Boundaries

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

      
        
        18
        +- Don't reorder files passed to `semantic.Build`

      
        
        19
        +

      
        
        20
        +## Verification

      
        
        21
        +```bash

      
        
        22
        +go build ./... && go test ./...

      
        
        23
        +```

      
M go.mod
···
        4
        4
         

      
        5
        5
         tool golang.org/x/tools/cmd/stringer

      
        6
        6
         

      
        7
        
        -require github.com/urfave/cli/v3 v3.10.0

      
        
        7
        +require (

      
        
        8
        +	github.com/urfave/cli/v3 v3.10.0

      
        
        9
        +	go.lsp.dev/jsonrpc2 v1.0.1

      
        
        10
        +	go.lsp.dev/protocol v1.0.1

      
        
        11
        +)

      
        8
        12
         

      
        9
        13
         require (

      
        
        14
        +	github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect

      
        
        15
        +	go.lsp.dev/uri v1.0.1 // indirect

      
        10
        16
         	golang.org/x/mod v0.36.0 // indirect

      
        11
        17
         	golang.org/x/sync v0.20.0 // indirect

      
        12
        18
         	golang.org/x/tools v0.45.0 // indirect

      
M go.sum
···
        1
        1
         github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

      
        2
        2
         github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

      
        3
        
        -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=

      
        4
        
        -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=

      
        
        3
        +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=

      
        
        4
        +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=

      
        
        5
        +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=

      
        
        6
        +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=

      
        5
        7
         github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

      
        6
        8
         github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

      
        7
        9
         github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

      
        8
        10
         github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=

      
        9
        11
         github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM=

      
        10
        12
         github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=

      
        
        13
        +go.lsp.dev/jsonrpc2 v1.0.1 h1:JZAchpm6RcOuwEOnZ7kF3kA1EpEjdJZ/6rMSfOF3+F4=

      
        
        14
        +go.lsp.dev/jsonrpc2 v1.0.1/go.mod h1:WN+TesqrPumBmUdsuWyn9DHq3zl4i85L/qG0NLdj8qg=

      
        
        15
        +go.lsp.dev/protocol v1.0.1 h1:kLWUIk1v/deXxo1D0VO4avjZnfeuZ82mouJgJd/NbdU=

      
        
        16
        +go.lsp.dev/protocol v1.0.1/go.mod h1:e//i41aEqc9ml32xiNuneUwbjOjuvTA4+uv+AkssJgk=

      
        
        17
        +go.lsp.dev/uri v1.0.1 h1:ywxJN1UXoQ+v0I9lkoQNMmsm7lub5G9ZG7KXT8YTx30=

      
        
        18
        +go.lsp.dev/uri v1.0.1/go.mod h1:06a0ghafs4PuqkLsJGUmlgPHw6+kH79edRXjOT/rA8s=

      
        11
        19
         golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=

      
        12
        20
         golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=

      
        13
        21
         golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=

      
M internal/cli/cli.go
···
        75
        75
         				}},

      
        76
        76
         			},

      
        77
        77
         			{

      
        
        78
        +				Name:   "lsp",

      
        
        79
        +				Usage:  "lsp server",

      
        
        80
        +				Action: c.lspAction,

      
        
        81
        +			},

      
        
        82
        +			{

      
        78
        83
         				Name:   "lint",

      
        79
        84
         				Usage:  "lint journal files for common mistakes",

      
        80
        85
         				Action: c.lintAction,

      
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)

      
A internal/cli/cmd_lsp.go
···
        
        1
        +package cli

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"os"

      
        
        6
        +

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

      
        
        8
        +

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

      
        
        10
        +)

      
        
        11
        +

      
        
        12
        +func (c *Cli) lspAction(ctx context.Context, cmd *cli.Command) error {

      
        
        13
        +	server := lsp.NewServer(c.version)

      
        
        14
        +

      
        
        15
        +	return server.Run(ctx, os.Stdin, os.Stdout)

      
        
        16
        +}

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

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"slices"

      
        
        6
        +	"strings"

      
        
        7
        +

      
        
        8
        +	"go.lsp.dev/protocol"

      
        
        9
        +

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

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +func (s *server) Completion(ctx context.Context, params *protocol.CompletionParams) (protocol.CompletionResult, error) {

      
        
        14
        +	text, ok := s.getDocText(params.TextDocument.URI)

      
        
        15
        +	if !ok {

      
        
        16
        +		return &protocol.CompletionList{IsIncomplete: false}, nil

      
        
        17
        +	}

      
        
        18
        +

      
        
        19
        +	lines := strings.Split(text, "\n")

      
        
        20
        +	lineIdx := int(params.Position.Line)

      
        
        21
        +	if lineIdx >= len(lines) {

      
        
        22
        +		lineIdx = len(lines) - 1

      
        
        23
        +	}

      
        
        24
        +	if lineIdx < 0 {

      
        
        25
        +		lineIdx = 0

      
        
        26
        +	}

      
        
        27
        +

      
        
        28
        +	currentLine := lines[lineIdx]

      
        
        29
        +	col := min(params.Position.Character, uint32(len(currentLine)))

      
        
        30
        +

      
        
        31
        +	prefix := currentLine[:col]

      
        
        32
        +	kind, word := detectKind(prefix)

      
        
        33
        +

      
        
        34
        +	s.semaMu.RLock()

      
        
        35
        +	sema := s.semanticCtx

      
        
        36
        +	s.semaMu.RUnlock()

      
        
        37
        +

      
        
        38
        +	var items []protocol.CompletionItem

      
        
        39
        +	switch kind {

      
        
        40
        +	case complAccount:

      
        
        41
        +		items = completeAccounts(sema, word)

      
        
        42
        +	case complPayee:

      
        
        43
        +		items = completePayees(sema, word)

      
        
        44
        +	case complCommodity:

      
        
        45
        +		items = completeCommodities(sema, word)

      
        
        46
        +	case complKeyword:

      
        
        47
        +		items = completeKeywords(word)

      
        
        48
        +	}

      
        
        49
        +	if items == nil {

      
        
        50
        +		items = []protocol.CompletionItem{}

      
        
        51
        +	}

      
        
        52
        +

      
        
        53
        +	return &protocol.CompletionList{

      
        
        54
        +		IsIncomplete: false,

      
        
        55
        +		Items:        items,

      
        
        56
        +	}, nil

      
        
        57
        +}

      
        
        58
        +

      
        
        59
        +type completionKind int

      
        
        60
        +

      
        
        61
        +const (

      
        
        62
        +	complNone completionKind = iota

      
        
        63
        +	complAccount

      
        
        64
        +	complPayee

      
        
        65
        +	complCommodity

      
        
        66
        +	complKeyword

      
        
        67
        +)

      
        
        68
        +

      
        
        69
        +func detectKind(prefix string) (completionKind, string) {

      
        
        70
        +	if prefix == "" {

      
        
        71
        +		return complNone, ""

      
        
        72
        +	}

      
        
        73
        +

      
        
        74
        +	trimmed := strings.TrimLeft(prefix, " \t")

      
        
        75
        +	if trimmed == "" {

      
        
        76
        +		return complNone, ""

      
        
        77
        +	}

      
        
        78
        +

      
        
        79
        +	c := prefix[0]

      
        
        80
        +

      
        
        81
        +	// posting line: starts with indentation

      
        
        82
        +	if c == ' ' || c == '\t' {

      
        
        83
        +		content := strings.TrimLeft(trimmed, "!* ([")

      
        
        84
        +		if _, after, ok := strings.Cut(content, "  "); ok {

      
        
        85
        +			// after := strings.TrimLeft(after, " \t")

      
        
        86
        +			if n := strings.LastIndexAny(after, " \t"); n >= 0 { // last field (after whitespace)

      
        
        87
        +				return complCommodity, after[n+1:]

      
        
        88
        +			}

      
        
        89
        +			return complCommodity, after

      
        
        90
        +		}

      
        
        91
        +		return complAccount, content

      
        
        92
        +	}

      
        
        93
        +

      
        
        94
        +	// directive keywords at line start

      
        
        95
        +	if kw, rest := matchKeyword(trimmed); kw != "" {

      
        
        96
        +		switch kw {

      
        
        97
        +		case "account", "alias":

      
        
        98
        +			return complAccount, rest

      
        
        99
        +		case "payee":

      
        
        100
        +			return complPayee, rest

      
        
        101
        +		case "commodity", "D", "P", "N":

      
        
        102
        +			return complCommodity, rest

      
        
        103
        +		}

      
        
        104
        +		return complNone, ""

      
        
        105
        +	}

      
        
        106
        +

      
        
        107
        +	// after date, transaction header

      
        
        108
        +	if c >= '0' && c <= '9' {

      
        
        109
        +		return complPayee, skipTransactionPreamble(trimmed)

      
        
        110
        +	}

      
        
        111
        +

      
        
        112
        +	// partial keyword on unknown line

      
        
        113
        +	if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {

      
        
        114
        +		return complKeyword, trimmed

      
        
        115
        +	}

      
        
        116
        +

      
        
        117
        +	return complNone, ""

      
        
        118
        +}

      
        
        119
        +

      
        
        120
        +func matchKeyword(trimmed string) (keyword, rest string) {

      
        
        121
        +	i := 0

      
        
        122
        +	for i < len(trimmed) && trimmed[i] != ' ' && trimmed[i] != '\t' {

      
        
        123
        +		i++

      
        
        124
        +	}

      
        
        125
        +	if i == 0 {

      
        
        126
        +		return "", ""

      
        
        127
        +	}

      
        
        128
        +	word := trimmed[:i]

      
        
        129
        +	switch word {

      
        
        130
        +	case "account", "alias", "payee", "commodity",

      
        
        131
        +		"include", "tag", "apply", "end",

      
        
        132
        +		"year", "Y", "decimal-mark",

      
        
        133
        +		"D", "P", "N", "C", "comment":

      
        
        134
        +	default:

      
        
        135
        +		return "", ""

      
        
        136
        +	}

      
        
        137
        +	rest = strings.TrimLeft(trimmed[i:], " \t")

      
        
        138
        +	return word, rest

      
        
        139
        +}

      
        
        140
        +

      
        
        141
        +func skipTransactionPreamble(trimmed string) string {

      
        
        142
        +	// Skip date (digits and date separators)

      
        
        143
        +	i := 0

      
        
        144
        +	for i < len(trimmed) {

      
        
        145
        +		c := trimmed[i]

      
        
        146
        +		if (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '.' {

      
        
        147
        +			i++

      
        
        148
        +		} else {

      
        
        149
        +			break

      
        
        150
        +		}

      
        
        151
        +	}

      
        
        152
        +	after := trimmed[i:]

      
        
        153
        +

      
        
        154
        +	// Skip whitespace, status markers, and code in parens

      
        
        155
        +	for {

      
        
        156
        +		before := after

      
        
        157
        +		after = strings.TrimLeft(after, " \t")

      
        
        158
        +		if len(after) > 0 && (after[0] == '*' || after[0] == '!') {

      
        
        159
        +			after = after[1:]

      
        
        160
        +			continue

      
        
        161
        +		}

      
        
        162
        +		if len(after) > 0 && after[0] == '(' {

      
        
        163
        +			end := strings.IndexByte(after, ')')

      
        
        164
        +			if end >= 0 {

      
        
        165
        +				after = after[end+1:]

      
        
        166
        +				continue

      
        
        167
        +			}

      
        
        168
        +		}

      
        
        169
        +		if after == before {

      
        
        170
        +			break

      
        
        171
        +		}

      
        
        172
        +	}

      
        
        173
        +	return after

      
        
        174
        +}

      
        
        175
        +

      
        
        176
        +func completeAccounts(sema *semantic.Context, prefix string) []protocol.CompletionItem {

      
        
        177
        +	if sema == nil || prefix == "" {

      
        
        178
        +		return nil

      
        
        179
        +	}

      
        
        180
        +	var items []protocol.CompletionItem

      
        
        181
        +	for name := range sema.Accounts {

      
        
        182
        +		if strings.HasPrefix(name, prefix) {

      
        
        183
        +			items = append(items, protocol.CompletionItem{

      
        
        184
        +				Label: name,

      
        
        185
        +				Kind:  protocol.CompletionItemKindVariable,

      
        
        186
        +				// Detail: protocol.NewOptional("account"),

      
        
        187
        +			})

      
        
        188
        +		}

      
        
        189
        +	}

      
        
        190
        +	slices.SortFunc(items, func(a, b protocol.CompletionItem) int {

      
        
        191
        +		return strings.Compare(a.Label, b.Label)

      
        
        192
        +	})

      
        
        193
        +	return items

      
        
        194
        +}

      
        
        195
        +

      
        
        196
        +func completePayees(sema *semantic.Context, prefix string) []protocol.CompletionItem {

      
        
        197
        +	if sema == nil {

      
        
        198
        +		return nil

      
        
        199
        +	}

      
        
        200
        +	var items []protocol.CompletionItem

      
        
        201
        +	for name := range sema.Payees {

      
        
        202
        +		if prefix != "" && !strings.HasPrefix(name, prefix) {

      
        
        203
        +			continue

      
        
        204
        +		}

      
        
        205
        +		items = append(items, protocol.CompletionItem{

      
        
        206
        +			Label: name,

      
        
        207
        +			Kind:  protocol.CompletionItemKindText,

      
        
        208
        +			// Detail: protocol.NewOptional("payee"),

      
        
        209
        +		})

      
        
        210
        +	}

      
        
        211
        +	slices.SortFunc(items, func(a, b protocol.CompletionItem) int {

      
        
        212
        +		return strings.Compare(a.Label, b.Label)

      
        
        213
        +	})

      
        
        214
        +	return items

      
        
        215
        +}

      
        
        216
        +

      
        
        217
        +func completeCommodities(sema *semantic.Context, prefix string) []protocol.CompletionItem {

      
        
        218
        +	if sema == nil {

      
        
        219
        +		return nil

      
        
        220
        +	}

      
        
        221
        +	var items []protocol.CompletionItem

      
        
        222
        +	for name := range sema.Commodities {

      
        
        223
        +		if prefix != "" && !strings.HasPrefix(name, prefix) {

      
        
        224
        +			continue

      
        
        225
        +		}

      
        
        226
        +		items = append(items, protocol.CompletionItem{

      
        
        227
        +			Label: name,

      
        
        228
        +			Kind:  protocol.CompletionItemKindConstant,

      
        
        229
        +			// Detail: protocol.NewOptional("commodity"),

      
        
        230
        +		})

      
        
        231
        +	}

      
        
        232
        +	slices.SortFunc(items, func(a, b protocol.CompletionItem) int {

      
        
        233
        +		return strings.Compare(a.Label, b.Label)

      
        
        234
        +	})

      
        
        235
        +	return items

      
        
        236
        +}

      
        
        237
        +

      
        
        238
        +// keywords

      
        
        239
        +

      
        
        240
        +type keywordInfo struct {

      
        
        241
        +	label, detail string

      
        
        242
        +}

      
        
        243
        +

      
        
        244
        +var directiveKeywords = []keywordInfo{

      
        
        245
        +	{"account", "define an account"},

      
        
        246
        +	{"alias", "alias an account name"},

      
        
        247
        +	{"apply", "apply a directive"},

      
        
        248
        +	{"comment", "comment block"},

      
        
        249
        +	{"commodity", "define a commodity"},

      
        
        250
        +	{"decimal-mark", "set decimal mark"},

      
        
        251
        +	{"end", "end a scoped directive"},

      
        
        252
        +	{"include", "include another file"},

      
        
        253
        +	{"payee", "define a payee"},

      
        
        254
        +	{"tag", "define a tag"},

      
        
        255
        +	{"year", "set default year"},

      
        
        256
        +	{"C", "commodity conversion"},

      
        
        257
        +	{"D", "set default commodity"},

      
        
        258
        +	{"N", "ignored price"},

      
        
        259
        +	{"P", "market price"},

      
        
        260
        +	{"Y", "set default year"},

      
        
        261
        +}

      
        
        262
        +

      
        
        263
        +func completeKeywords(prefix string) []protocol.CompletionItem {

      
        
        264
        +	var items []protocol.CompletionItem

      
        
        265
        +	for _, kw := range directiveKeywords {

      
        
        266
        +		if !strings.HasPrefix(kw.label, prefix) {

      
        
        267
        +			continue

      
        
        268
        +		}

      
        
        269
        +		items = append(items, protocol.CompletionItem{

      
        
        270
        +			Label:  kw.label,

      
        
        271
        +			Kind:   protocol.CompletionItemKindKeyword,

      
        
        272
        +			Detail: protocol.NewOptional(kw.detail),

      
        
        273
        +		})

      
        
        274
        +	}

      
        
        275
        +	return items

      
        
        276
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"time"

      
        
        7
        +

      
        
        8
        +	"go.lsp.dev/jsonrpc2"

      
        
        9
        +	"go.lsp.dev/protocol"

      
        
        10
        +	"go.lsp.dev/uri"

      
        
        11
        +

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

      
        
        13
        +	"olexsmir.xyz/clerk/journal"

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

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

      
        
        16
        +)

      
        
        17
        +

      
        
        18
        +func (s *server) rebuildWorkspace(ctx context.Context) {

      
        
        19
        +	s.logger.Debug("rebuilding workspace")

      
        
        20
        +

      
        
        21
        +	s.mu.Lock()

      
        
        22
        +	defer s.mu.Unlock()

      
        
        23
        +

      
        
        24
        +	if len(s.openDocs) == 0 {

      
        
        25
        +		return

      
        
        26
        +	}

      
        
        27
        +

      
        
        28
        +	// reload all open docs from in-memory content

      
        
        29
        +	for duri, state := range s.openDocs {

      
        
        30
        +		path := duri.Path()

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

      
        
        32
        +			s.logger.Warn("failed to relod open document", "uri", duri, "err", err)

      
        
        33
        +		}

      
        
        34
        +	}

      
        
        35
        +

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

      
        
        37
        +	if len(roots) == 0 {

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

      
        
        39
        +		return

      
        
        40
        +	}

      
        
        41
        +

      
        
        42
        +	var allFiles []*journal.ParsedFile

      
        
        43
        +	var finds []linter.Find

      
        
        44
        +	for _, root := range roots {

      
        
        45
        +		files := journal.CollectFiles(root)

      
        
        46
        +		c := semantic.Build(files)

      
        
        47
        +		finds = append(finds, s.linter.Run(c)...)

      
        
        48
        +		allFiles = append(allFiles, files...)

      
        
        49
        +	}

      
        
        50
        +	finds = dedupFinds(finds)

      
        
        51
        +

      
        
        52
        +	s.semaMu.Lock()

      
        
        53
        +	s.semanticCtx = semantic.Build(allFiles)

      
        
        54
        +	s.semaMu.Unlock()

      
        
        55
        +

      
        
        56
        +	diagsByFile := groupFindsByFile(finds)

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

      
        
        58
        +	for fpath := range activeFiles {

      
        
        59
        +		diags := diagsByFile[fpath]

      
        
        60
        +		if diags == nil { // TODO: not sure if it's needed

      
        
        61
        +			diags = []protocol.Diagnostic{}

      
        
        62
        +		}

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

      
        
        64
        +			URI:         uri.File(fpath),

      
        
        65
        +			Diagnostics: diags,

      
        
        66
        +		}); err != nil {

      
        
        67
        +			s.logger.Warn("publish diagnostics failed", "uri", uri.File(fpath), "err", err)

      
        
        68
        +		}

      
        
        69
        +	}

      
        
        70
        +

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

      
        
        72
        +}

      
        
        73
        +

      
        
        74
        +func (s *server) scheduleWorkspaceRebuild(ctx context.Context) {

      
        
        75
        +	s.mu.Lock()

      
        
        76
        +	if s.debouncer != nil {

      
        
        77
        +		s.debouncer.Stop()

      
        
        78
        +	}

      
        
        79
        +	ctx = jsonrpc2.DetachContext(ctx)

      
        
        80
        +	s.debouncer = time.AfterFunc(200*time.Millisecond, func() {

      
        
        81
        +		s.rebuildWorkspace(ctx)

      
        
        82
        +	})

      
        
        83
        +	s.mu.Unlock()

      
        
        84
        +}

      
        
        85
        +

      
        
        86
        +func (s *server) cancelWorkspaceRebuild() {

      
        
        87
        +	s.mu.Lock()

      
        
        88
        +	if s.debouncer != nil {

      
        
        89
        +		s.debouncer.Stop()

      
        
        90
        +		s.debouncer = nil

      
        
        91
        +	}

      
        
        92
        +	s.mu.Unlock()

      
        
        93
        +}

      
        
        94
        +

      
        
        95
        +func activeFileSet(docs map[uri.URI]docState, ordered []*journal.ParsedFile) map[string]bool {

      
        
        96
        +	active := make(map[string]bool)

      
        
        97
        +	for duri := range docs {

      
        
        98
        +		active[duri.Path()] = true

      
        
        99
        +	}

      
        
        100
        +

      
        
        101
        +	visited := make(map[string]bool)

      
        
        102
        +	var walk func(*journal.ParsedFile)

      
        
        103
        +	walk = func(pf *journal.ParsedFile) {

      
        
        104
        +		if visited[pf.Path] {

      
        
        105
        +			return

      
        
        106
        +		}

      
        
        107
        +		active[pf.Path] = true

      
        
        108
        +		visited[pf.Path] = true

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

      
        
        110
        +			walk(inc)

      
        
        111
        +		}

      
        
        112
        +	}

      
        
        113
        +

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

      
        
        115
        +	for _, pf := range ordered {

      
        
        116
        +		byPath[pf.Path] = pf

      
        
        117
        +	}

      
        
        118
        +	for duri := range docs {

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

      
        
        120
        +			walk(pf)

      
        
        121
        +		}

      
        
        122
        +	}

      
        
        123
        +

      
        
        124
        +	return active

      
        
        125
        +}

      
        
        126
        +

      
        
        127
        +func groupFindsByFile(finds []linter.Find) map[string][]protocol.Diagnostic {

      
        
        128
        +	diags := make(map[string][]protocol.Diagnostic)

      
        
        129
        +	for _, find := range finds {

      
        
        130
        +		file := find.Span.Start.File

      
        
        131
        +		if file == "" {

      
        
        132
        +			continue

      
        
        133
        +		}

      
        
        134
        +		diags[file] = append(diags[file], findToDiagnostic(find))

      
        
        135
        +	}

      
        
        136
        +	return diags

      
        
        137
        +}

      
        
        138
        +

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

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

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

      
        
        142
        +	for _, f := range finds {

      
        
        143
        +		s := f.Span.Start

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

      
        
        145
        +		if seen[key] {

      
        
        146
        +			continue

      
        
        147
        +		}

      
        
        148
        +		seen[key] = true

      
        
        149
        +		dedup = append(dedup, f)

      
        
        150
        +	}

      
        
        151
        +	return dedup

      
        
        152
        +}

      
        
        153
        +

      
        
        154
        +func findToDiagnostic(find linter.Find) protocol.Diagnostic {

      
        
        155
        +	return protocol.Diagnostic{

      
        
        156
        +		Range:    spanToRange(find.Span),

      
        
        157
        +		Severity: severityToLSP(find.Severity),

      
        
        158
        +		Message:  protocol.String(find.Message),

      
        
        159
        +		Source:   protocol.NewOptional(name),

      
        
        160
        +		Code:     protocol.String(string(find.Code)),

      
        
        161
        +	}

      
        
        162
        +}

      
        
        163
        +

      
        
        164
        +func spanToRange(span token.Span) protocol.Range {

      
        
        165
        +	return protocol.Range{

      
        
        166
        +		Start: protocol.Position{

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

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

      
        
        169
        +		},

      
        
        170
        +		End: protocol.Position{

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

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

      
        
        173
        +		},

      
        
        174
        +	}

      
        
        175
        +}

      
        
        176
        +

      
        
        177
        +func severityToLSP(s linter.Severity) protocol.DiagnosticSeverity {

      
        
        178
        +	switch s {

      
        
        179
        +	case linter.SeverityError:

      
        
        180
        +		return protocol.DiagnosticSeverityError

      
        
        181
        +	case linter.SeverityWarning:

      
        
        182
        +		return protocol.DiagnosticSeverityWarning

      
        
        183
        +	case linter.SeverityInfo:

      
        
        184
        +		return protocol.DiagnosticSeverityInformation

      
        
        185
        +	case linter.SeverityHint:

      
        
        186
        +		return protocol.DiagnosticSeverityHint

      
        
        187
        +	default:

      
        
        188
        +		panic("impossible diagnostic severity")

      
        
        189
        +	}

      
        
        190
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"io"

      
        
        6
        +	"log/slog"

      
        
        7
        +	"os"

      
        
        8
        +

      
        
        9
        +	"go.lsp.dev/jsonrpc2"

      
        
        10
        +	"go.lsp.dev/protocol"

      
        
        11
        +	"go.lsp.dev/uri"

      
        
        12
        +

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

      
        
        14
        +	"olexsmir.xyz/clerk/journal"

      
        
        15
        +	"olexsmir.xyz/clerk/journal/printer"

      
        
        16
        +)

      
        
        17
        +

      
        
        18
        +type Server struct{ server *server }

      
        
        19
        +

      
        
        20
        +func NewServer(version string) *Server {

      
        
        21
        +	return &Server{

      
        
        22
        +		server: &server{

      
        
        23
        +			version:  version,

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

      
        
        25
        +

      
        
        26
        +			loader:     journal.NewLoader(),

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

      
        
        28
        +			printerCfg: printer.DefaultConfig,

      
        
        29
        +

      
        
        30
        +			logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{})),

      
        
        31
        +		},

      
        
        32
        +	}

      
        
        33
        +}

      
        
        34
        +

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

      
        
        36
        +	stream := jsonrpc2.NewStream(readWriterCloser{

      
        
        37
        +		Reader: stdin,

      
        
        38
        +		Writer: stdout,

      
        
        39
        +		Closer: stdin,

      
        
        40
        +	})

      
        
        41
        +

      
        
        42
        +	_, conn, client := protocol.NewServer(ctx, s.server, stream)

      
        
        43
        +	defer conn.Close()

      
        
        44
        +

      
        
        45
        +	s.server.client = client

      
        
        46
        +

      
        
        47
        +	<-conn.Done()

      
        
        48
        +	return conn.Err()

      
        
        49
        +}

      
        
        50
        +

      
        
        51
        +type readWriterCloser struct {

      
        
        52
        +	io.Reader

      
        
        53
        +	io.Writer

      
        
        54
        +	io.Closer

      
        
        55
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"log/slog"

      
        
        6
        +	"sync"

      
        
        7
        +	"time"

      
        
        8
        +

      
        
        9
        +	"go.lsp.dev/protocol"

      
        
        10
        +	"go.lsp.dev/uri"

      
        
        11
        +

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

      
        
        13
        +	"olexsmir.xyz/clerk/journal"

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

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

      
        
        16
        +)

      
        
        17
        +

      
        
        18
        +const name = "clerk"

      
        
        19
        +

      
        
        20
        +type docState struct {

      
        
        21
        +	text       string

      
        
        22
        +	version    int32

      
        
        23
        +	languageID protocol.LanguageKind

      
        
        24
        +}

      
        
        25
        +

      
        
        26
        +type server struct {

      
        
        27
        +	protocol.UnimplementedServer

      
        
        28
        +

      
        
        29
        +	client protocol.Client

      
        
        30
        +

      
        
        31
        +	version string

      
        
        32
        +

      
        
        33
        +	printerCfg *printer.Config

      
        
        34
        +	logger     *slog.Logger

      
        
        35
        +

      
        
        36
        +	loader    *journal.Loader

      
        
        37
        +	linter    *linter.Linter

      
        
        38
        +	debouncer *time.Timer

      
        
        39
        +

      
        
        40
        +	mu          sync.Mutex

      
        
        41
        +	openDocs    map[uri.URI]docState

      
        
        42
        +	semanticCtx *semantic.Context

      
        
        43
        +	semaMu      sync.RWMutex

      
        
        44
        +}

      
        
        45
        +

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

      
        
        47
        +	return &protocol.InitializeResult{

      
        
        48
        +		ServerInfo: protocol.ServerInfo{

      
        
        49
        +			Name:    name,

      
        
        50
        +			Version: protocol.NewOptional(s.version),

      
        
        51
        +		},

      
        
        52
        +		Capabilities: protocol.ServerCapabilities{

      
        
        53
        +			TextDocumentSync: &protocol.TextDocumentSyncOptions{

      
        
        54
        +				OpenClose: new(true),

      
        
        55
        +				Change:    new(protocol.TextDocumentSyncKindFull),

      
        
        56
        +			},

      
        
        57
        +			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},

      
        
        58
        +			CompletionProvider: &protocol.CompletionOptions{

      
        
        59
        +				TriggerCharacters: []string{":", "/"},

      
        
        60
        +			},

      
        
        61
        +		},

      
        
        62
        +	}, nil

      
        
        63
        +}

      
        
        64
        +

      
        
        65
        +func (s *server) Initialized(ctx context.Context, params *protocol.InitializedParams) error {

      
        
        66
        +	s.scheduleWorkspaceRebuild(ctx)

      
        
        67
        +	return nil

      
        
        68
        +}

      
        
        69
        +

      
        
        70
        +func (s *server) Shutdown(ctx context.Context) error {

      
        
        71
        +	s.cancelWorkspaceRebuild()

      
        
        72
        +	return nil

      
        
        73
        +}

      
        
        74
        +

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

      
        
        76
        +	return nil

      
        
        77
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"fmt"

      
        
        6
        +	"strings"

      
        
        7
        +

      
        
        8
        +	"go.lsp.dev/protocol"

      
        
        9
        +

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

      
        
        11
        +	"olexsmir.xyz/clerk/journal/parser"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +func (s *server) Formatting(ctx context.Context, params *protocol.DocumentFormattingParams) ([]protocol.TextEdit, error) {

      
        
        15
        +	text, ok := s.getDocText(params.TextDocument.URI)

      
        
        16
        +	if !ok {

      
        
        17
        +		return nil, nil

      
        
        18
        +	}

      
        
        19
        +

      
        
        20
        +	lex := lexer.New(params.TextDocument.URI.Path(), []byte(text))

      
        
        21
        +	jnrl := parser.New(lex).ParseJournal()

      
        
        22
        +	if len(jnrl.Errors) > 0 {

      
        
        23
        +		return nil, fmt.Errorf("can't format file with errors: %v", jnrl.Errors[0].Message)

      
        
        24
        +	}

      
        
        25
        +

      
        
        26
        +	var buf strings.Builder

      
        
        27
        +	if err := s.printerCfg.Fprint(&buf, jnrl); err != nil {

      
        
        28
        +		return nil, fmt.Errorf("format: %w", err)

      
        
        29
        +	}

      
        
        30
        +	newText := buf.String()

      
        
        31
        +

      
        
        32
        +	if newText == text {

      
        
        33
        +		return nil, nil

      
        
        34
        +	}

      
        
        35
        +

      
        
        36
        +	endLine, endChar := getEndLineAndChar(text)

      
        
        37
        +	return []protocol.TextEdit{{

      
        
        38
        +		NewText: newText,

      
        
        39
        +		Range: protocol.Range{

      
        
        40
        +			Start: protocol.Position{

      
        
        41
        +				Line:      0,

      
        
        42
        +				Character: 0,

      
        
        43
        +			},

      
        
        44
        +			End: protocol.Position{

      
        
        45
        +				Line:      uint32(endLine),

      
        
        46
        +				Character: uint32(endChar),

      
        
        47
        +			},

      
        
        48
        +		},

      
        
        49
        +	}}, nil

      
        
        50
        +}

      
        
        51
        +

      
        
        52
        +func getEndLineAndChar(txt string) (int, int) {

      
        
        53
        +	endLine := 0

      
        
        54
        +	lastNewline := -1

      
        
        55
        +	for i, ch := range txt {

      
        
        56
        +		if ch == '\n' {

      
        
        57
        +			endLine++

      
        
        58
        +			lastNewline = i

      
        
        59
        +		}

      
        
        60
        +	}

      
        
        61
        +	endChar := 0

      
        
        62
        +	if lastNewline >= 0 {

      
        
        63
        +		if lastNewline == len(txt)-1 {

      
        
        64
        +			endLine++

      
        
        65
        +			endChar = 0

      
        
        66
        +		} else {

      
        
        67
        +			endChar = len(txt) - lastNewline - 1

      
        
        68
        +		}

      
        
        69
        +	} else {

      
        
        70
        +		endChar = len(txt)

      
        
        71
        +	}

      
        
        72
        +	return endLine, endChar

      
        
        73
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +

      
        
        6
        +	"go.lsp.dev/protocol"

      
        
        7
        +	"go.lsp.dev/uri"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +func (s *server) DidOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error {

      
        
        11
        +	s.openDoc(params.TextDocument.URI,

      
        
        12
        +		params.TextDocument.Text,

      
        
        13
        +		params.TextDocument.Version,

      
        
        14
        +		params.TextDocument.LanguageID)

      
        
        15
        +	s.scheduleWorkspaceRebuild(ctx)

      
        
        16
        +	return nil

      
        
        17
        +}

      
        
        18
        +

      
        
        19
        +func (s *server) DidChange(ctx context.Context, params *protocol.DidChangeTextDocumentParams) error {

      
        
        20
        +	s.updateDoc(params.TextDocument.URI, params.TextDocument.Version, params.ContentChanges)

      
        
        21
        +	s.scheduleWorkspaceRebuild(ctx)

      
        
        22
        +	return nil

      
        
        23
        +}

      
        
        24
        +

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

      
        
        26
        +	s.closeDoc(params.TextDocument.URI)

      
        
        27
        +	s.scheduleWorkspaceRebuild(ctx)

      
        
        28
        +	return nil

      
        
        29
        +}

      
        
        30
        +

      
        
        31
        +func (s *server) DidSave(ctx context.Context, params *protocol.DidSaveTextDocumentParams) error {

      
        
        32
        +	return nil

      
        
        33
        +}

      
        
        34
        +

      
        
        35
        +// Document management

      
        
        36
        +

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

      
        
        38
        +	s.mu.Lock()

      
        
        39
        +	s.openDocs[u] = docState{

      
        
        40
        +		text:       text,

      
        
        41
        +		version:    version,

      
        
        42
        +		languageID: langID,

      
        
        43
        +	}

      
        
        44
        +	s.mu.Unlock()

      
        
        45
        +}

      
        
        46
        +

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

      
        
        48
        +	s.mu.Lock()

      
        
        49
        +	defer s.mu.Unlock()

      
        
        50
        +

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

      
        
        52
        +	if !ok {

      
        
        53
        +		return

      
        
        54
        +	}

      
        
        55
        +	state.version = version

      
        
        56
        +	for _, ch := range changes {

      
        
        57
        +		switch ev := ch.(type) {

      
        
        58
        +		case *protocol.TextDocumentContentChangeWholeDocument:

      
        
        59
        +			state.text = ev.Text

      
        
        60
        +		case *protocol.TextDocumentContentChangePartial:

      
        
        61
        +			_ = ev // TODO: incremental edit support

      
        
        62
        +		}

      
        
        63
        +	}

      
        
        64
        +	s.openDocs[u] = state

      
        
        65
        +}

      
        
        66
        +

      
        
        67
        +func (s *server) closeDoc(u uri.URI) {

      
        
        68
        +	s.mu.Lock()

      
        
        69
        +	delete(s.openDocs, u)

      
        
        70
        +	s.mu.Unlock()

      
        
        71
        +}

      
        
        72
        +

      
        
        73
        +func (s *server) getDocText(u uri.URI) (string, bool) {

      
        
        74
        +	s.mu.Lock()

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

      
        
        76
        +	s.mu.Unlock()

      
        
        77
        +	return state.text, ok

      
        
        78
        +}

      
M journal/loader.go
···
        37
        37
         	return l.loadBytes(path, src, nil)

      
        38
        38
         }

      
        39
        39
         

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

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

      
        
        42
        +	if err != nil {

      
        
        43
        +		return nil, err

      
        
        44
        +	}

      
        
        45
        +	delete(l.files, abs)

      
        
        46
        +	return l.loadBytes(abs, src, nil)

      
        
        47
        +}

      
        
        48
        +

      
        
        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
        +

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

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

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

      
M journal/printer/printer.go
···
        34
        34
         	CommodityPos       CommodityPos // where to place commodity

      
        35
        35
         }

      
        36
        36
         

      
        37
        
        -var defaultConfig = &Config{

      
        
        37
        +var DefaultConfig = &Config{

      
        38
        38
         	TabIndent:          false,

      
        39
        39
         	IndentWidth:        2,

      
        40
        40
         	PreserveBlankLines: false,

      ···
        63
        63
         }

      
        64
        64
         

      
        65
        65
         // Fprint formats using the default config.

      
        66
        
        -func Fprint(w io.Writer, j *ast.Journal) error { return defaultConfig.Fprint(w, j) }

      
        
        66
        +func Fprint(w io.Writer, j *ast.Journal) error { return DefaultConfig.Fprint(w, j) }

      
        67
        67
         

      
        68
        68
         // Fprint formats a parsed journal.

      
        69
        69
         func (c *Config) Fprint(w io.Writer, j *ast.Journal) error {

      
        70
        70
         	if c == nil {

      
        71
        
        -		c = defaultConfig

      
        
        71
        +		c = DefaultConfig

      
        72
        72
         	}

      
        73
        73
         	p := printer{cfg: c, indent: c.indent()}

      
        74
        74
         

      ···
        83
        83
         }

      
        84
        84
         

      
        85
        85
         // FprintEntry formats a single ast entry using the default config.

      
        86
        
        -func FprintEntry(w io.Writer, e ast.Entry) error { return defaultConfig.FprintEntry(w, e) }

      
        
        86
        +func FprintEntry(w io.Writer, e ast.Entry) error { return DefaultConfig.FprintEntry(w, e) }

      
        87
        87
         

      
        88
        88
         // FprintEntry formats a single journal entry.

      
        89
        89
         func (c *Config) FprintEntry(w io.Writer, e ast.Entry) error {

      
        90
        90
         	if c == nil {

      
        91
        
        -		c = defaultConfig

      
        
        91
        +		c = DefaultConfig

      
        92
        92
         	}

      
        93
        93
         	p := printer{cfg: c, indent: c.indent()}

      
        94
        94
         	p.formatEntry(e)

      
M journal/printer/printer_test.go
···
        20
        20
         				t.Fatal(err)

      
        21
        21
         			}

      
        22
        22
         			var b strings.Builder

      
        23
        
        -			if err := defaultConfig.Fprint(&b, pf.Ast); err != nil {

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

      
        24
        24
         				t.Fatal(err)

      
        25
        25
         			}

      
        26
        26
         

      ···
        41
        41
         			b.ResetTimer()

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

      
        43
        43
         				var buf strings.Builder

      
        44
        
        -				if err := defaultConfig.Fprint(&buf, pf.Ast); err != nil {

      
        
        44
        +				if err := DefaultConfig.Fprint(&buf, pf.Ast); err != nil {

      
        45
        45
         					b.Fatal(err)

      
        46
        46
         				}

      
        47
        47
         			}