all repos

clerk @ 8063300323d837a6fc6df555d8ae746f6da318a1

missing tooling for ledger/hledger
5 files changed, 55 insertions(+), 16 deletions(-)
perf: lsp: dedupe and group diagnostics without string keys

The 1k journal produces ~10k findings per lint;
dedup built a fmt.Sprintf key per find and grouping grew diagnostic slices
unbounded. Key dedup by struct, pre-size per-file slices from a
count pass, and build rule messages by concatenation.
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-13 13:40:52 +0300
Authored at: 2026-08-13 13:24:13 +0300
Change ID: zlsoytnstwqqnurntpplvnvkwnqkqyum
Parent: 1925839
M internal/linter/rule_undeclared_account.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"fmt"

      
        5
        
        -

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

      
        7
        5
         )

      
        8
        6
         

      ···
        25
        23
         				Code:     u.ID(),

      
        26
        24
         				Severity: u.Severity(),

      
        27
        25
         				Span:     usage.Posting.Account.Span,

      
        28
        
        -				Message:  fmt.Sprintf("undeclared account: %s", name),

      
        
        26
        +				Message:  "undeclared account: " + name,

      
        29
        27
         			})

      
        30
        28
         		}

      
        31
        29
         	}

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

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"fmt"

      
        5
        
        -

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

      
        7
        5
         )

      
        8
        6
         

      ···
        22
        20
         				Code:     u.ID(),

      
        23
        21
         				Severity: u.Severity(),

      
        24
        22
         				Span:     usage.Amount.Span,

      
        25
        
        -				Message:  fmt.Sprintf("undeclared commodity: %s", name),

      
        
        23
        +				Message:  "undeclared commodity: " + name,

      
        26
        24
         			})

      
        27
        25
         		}

      
        28
        26
         	}

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

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"fmt"

      
        5
        
        -

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

      
        7
        5
         )

      
        8
        6
         

      ···
        25
        23
         				Code:     u.ID(),

      
        26
        24
         				Severity: u.Severity(),

      
        27
        25
         				Span:     usage.Payee.Span,

      
        28
        
        -				Message:  fmt.Sprintf("undeclared payee: %s", name),

      
        
        26
        +				Message:  "undeclared payee: " + name,

      
        29
        27
         			})

      
        30
        28
         		}

      
        31
        29
         	}

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

      
        3
        3
         import (

      
        4
        4
         	"context"

      
        5
        
        -	"fmt"

      
        6
        5
         	"time"

      
        7
        6
         

      
        8
        7
         	"go.lsp.dev/jsonrpc2"

      ···
        84
        83
         }

      
        85
        84
         

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

      
        87
        
        -	diags := make(map[string][]protocol.Diagnostic)

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

      
        
        87
        +	// findings is the dominant allocation in the diagnostics path

      
        
        88
        +	counts := make(map[string]int, len(finds))

      
        
        89
        +	for _, find := range finds {

      
        
        90
        +		if find.Span.Start.File != "" {

      
        
        91
        +			counts[find.Span.Start.File]++

      
        
        92
        +		}

      
        
        93
        +	}

      
        
        94
        +	diags := make(map[string][]protocol.Diagnostic, len(counts))

      
        
        95
        +	for fpath, n := range counts {

      
        
        96
        +		diags[fpath] = make([]protocol.Diagnostic, 0, n)

      
        
        97
        +	}

      
        88
        98
         	for _, find := range finds {

      
        89
        99
         		file := find.Span.Start.File

      
        90
        100
         		if file == "" {

      ···
        106
        116
         }

      
        107
        117
         

      
        108
        118
         func dedupFinds(finds []linter.Find) []linter.Find {

      
        109
        
        -	seen := make(map[string]bool)

      
        
        119
        +	seen := make(map[findKey]bool, len(finds))

      
        110
        120
         	dedup := make([]linter.Find, 0, len(finds))

      
        111
        121
         	for _, f := range finds {

      
        112
        
        -		s := f.Span.Start

      
        113
        
        -		key := fmt.Sprintf("%s:%d:%d:%s", s.File, s.Line, s.Col, f.Code) // TODO: performace

      
        114
        
        -		if seen[key] {

      
        
        122
        +		k := findKey{f.Span.Start.File, f.Span.Start.Line, f.Span.Start.Col, f.Code}

      
        
        123
        +		if seen[k] {

      
        115
        124
         			continue

      
        116
        125
         		}

      
        117
        
        -		seen[key] = true

      
        
        126
        +		seen[k] = true

      
        118
        127
         		dedup = append(dedup, f)

      
        119
        128
         	}

      
        120
        129
         	return dedup

      
        
        130
        +}

      
        
        131
        +

      
        
        132
        +// findKey identifies a find by its position and rule; a struct key avoids a

      
        
        133
        +// per-find fmt.Sprintf.

      
        
        134
        +type findKey struct {

      
        
        135
        +	file      string

      
        
        136
        +	line, col int

      
        
        137
        +	code      linter.RuleID

      
        121
        138
         }

      
        122
        139
         

      
        123
        140
         func spanToRange(span token.Span) protocol.Range {

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"os"

      
        
        5
        +	"testing"

      
        
        6
        +

      
        
        7
        +	"go.lsp.dev/uri"

      
        
        8
        +)

      
        
        9
        +

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

      
        
        11
        +	path := "../../journal/testdata/journals/actual-1ktxns-100accts.journal"

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

      
        
        13
        +	if err != nil {

      
        
        14
        +		b.Fatal(err)

      
        
        15
        +	}

      
        
        16
        +

      
        
        17
        +	srv := NewServer("test")

      
        
        18
        +	srv.server.openDoc(uri.URI("file:///test.journal"), string(src), 1, "journal")

      
        
        19
        +

      
        
        20
        +	b.ReportAllocs()

      
        
        21
        +	b.ResetTimer()

      
        
        22
        +	for b.Loop() {

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

      
        
        24
        +		a := srv.server.buildAnalysis()

      
        
        25
        +		finds := dedupFinds(srv.server.linter.Run(a))

      
        
        26
        +		_ = srv.server.groupFindsByFile(finds)

      
        
        27
        +	}

      
        
        28
        +}