clerk/journal/tags/tag.go (view raw)
| 1 | package tags |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "path/filepath" |
| 6 | "sort" |
| 7 | |
| 8 | "olexsmir.xyz/clerk/internal/tags" |
| 9 | "olexsmir.xyz/clerk/journal" |
| 10 | "olexsmir.xyz/clerk/journal/ast" |
| 11 | "olexsmir.xyz/clerk/journal/token" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | KindAccount = 'a' |
| 16 | KindAccountDesc = "account" |
| 17 | |
| 18 | KindCommodity = 'c' |
| 19 | KindCommodityDesc = "commodity" |
| 20 | |
| 21 | KindPayee = 'p' |
| 22 | KindPayeeDesc = "payee" |
| 23 | |
| 24 | KindTag = 't' |
| 25 | KindTagDesc = "tag" |
| 26 | ) |
| 27 | |
| 28 | // priority constants for source types - lower wins. |
| 29 | const ( |
| 30 | prioTagDirective = 0 |
| 31 | |
| 32 | prioAccountDirective = 0 |
| 33 | prioAliasDirective = 1 |
| 34 | prioAccountPosting = 2 |
| 35 | |
| 36 | prioCommodityDirective = 0 |
| 37 | prioMarketPrice = 1 |
| 38 | prioAmountCommodity = 2 |
| 39 | |
| 40 | prioPayeeDirective = 0 |
| 41 | prioPayeeTransaction = 1 |
| 42 | ) |
| 43 | |
| 44 | // candidate is a potential tag before sorting and dedup. |
| 45 | type candidate struct { |
| 46 | name string |
| 47 | kind byte |
| 48 | file string |
| 49 | line int |
| 50 | priority int |
| 51 | src []byte |
| 52 | offset int |
| 53 | } |
| 54 | |
| 55 | type Tagger struct { |
| 56 | loader *journal.Loader |
| 57 | relDir string |
| 58 | |
| 59 | candidates []candidate |
| 60 | } |
| 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} |
| 65 | } |
| 66 | |
| 67 | // Write generate tags file and write it in tags format. |
| 68 | func (t *Tagger) Write(w io.Writer) error { |
| 69 | tw := tags.NewWriter() |
| 70 | tw.DescribeKind(KindAccount, KindAccountDesc) |
| 71 | tw.DescribeKind(KindCommodity, KindCommodityDesc) |
| 72 | tw.DescribeKind(KindPayee, KindPayeeDesc) |
| 73 | tw.DescribeKind(KindTag, KindTagDesc) |
| 74 | for _, e := range t.collect() { |
| 75 | tw.Add(e) |
| 76 | } |
| 77 | return tw.Write(w) |
| 78 | } |
| 79 | |
| 80 | func (t *Tagger) collect() []tags.Entry { |
| 81 | t.candidates = nil |
| 82 | |
| 83 | for _, pf := range t.loader.Ordered() { |
| 84 | filePath := pf.Path |
| 85 | if t.relDir != "" { |
| 86 | filePath = relativePath(filePath, t.relDir) |
| 87 | } |
| 88 | for _, entry := range pf.Ast.Entries { |
| 89 | t.collectFromEntry(pf, filePath, entry) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // sort by priority |
| 94 | sort.Slice(t.candidates, func(i, j int) bool { |
| 95 | if t.candidates[i].name != t.candidates[j].name { |
| 96 | return t.candidates[i].name < t.candidates[j].name |
| 97 | } |
| 98 | if t.candidates[i].kind != t.candidates[j].kind { |
| 99 | return t.candidates[i].kind < t.candidates[j].kind |
| 100 | } |
| 101 | if t.candidates[i].priority != t.candidates[j].priority { |
| 102 | return t.candidates[i].priority < t.candidates[j].priority |
| 103 | } |
| 104 | if t.candidates[i].file != t.candidates[j].file { |
| 105 | return t.candidates[i].file < t.candidates[j].file |
| 106 | } |
| 107 | return t.candidates[i].line < t.candidates[j].line |
| 108 | }) |
| 109 | |
| 110 | // deduplicate |
| 111 | var entries []tags.Entry |
| 112 | for i, c := range t.candidates { |
| 113 | if i > 0 && t.candidates[i-1].name == c.name && t.candidates[i-1].kind == c.kind { |
| 114 | continue |
| 115 | } |
| 116 | entries = append(entries, tags.Entry{ |
| 117 | Name: c.name, |
| 118 | File: c.file, |
| 119 | Line: c.line, |
| 120 | Pattern: extractLine(c.src, c.offset), |
| 121 | Kind: c.kind, |
| 122 | Language: "hledger", |
| 123 | }) |
| 124 | } |
| 125 | |
| 126 | return entries |
| 127 | } |
| 128 | |
| 129 | func (t *Tagger) collectFromEntry(pf *journal.ParsedFile, fpath string, entry ast.Entry) { |
| 130 | switch e := entry.(type) { |
| 131 | case *ast.AccountDirective: |
| 132 | t.addCandidate(e.Account.Name, fpath, e.Account.Span, pf.Src, KindAccount, prioAccountDirective) |
| 133 | |
| 134 | case *ast.CommodityDirective: |
| 135 | t.addCandidate(e.Commodity, fpath, e.Span, pf.Src, KindCommodity, prioCommodityDirective) |
| 136 | |
| 137 | case *ast.PayeeDirective: |
| 138 | t.addCandidate(e.Name, fpath, e.Span, pf.Src, KindPayee, prioPayeeDirective) |
| 139 | |
| 140 | case *ast.TagDirective: |
| 141 | t.addCandidate(e.Name, fpath, e.Span, pf.Src, KindTag, prioTagDirective) |
| 142 | |
| 143 | case *ast.Transaction: |
| 144 | if e.Payee != nil { |
| 145 | t.addCandidate(e.Payee.Name, fpath, e.Payee.Span, pf.Src, KindPayee, prioPayeeTransaction) |
| 146 | } |
| 147 | for _, p := range e.Postings { |
| 148 | t.collectFromPosting(p, fpath, pf.Src) |
| 149 | } |
| 150 | |
| 151 | case *ast.PeriodicTransaction: |
| 152 | for _, p := range e.Postings { |
| 153 | t.collectFromPosting(p, fpath, pf.Src) |
| 154 | } |
| 155 | |
| 156 | case *ast.AutomatedTransaction: |
| 157 | for _, p := range e.Postings { |
| 158 | t.collectFromPosting(p, fpath, pf.Src) |
| 159 | } |
| 160 | |
| 161 | case *ast.MarketPriceDirective: |
| 162 | t.addCandidate(e.Commodity, fpath, e.Span, pf.Src, KindCommodity, prioMarketPrice) |
| 163 | if e.Amount.Commodity != "" { |
| 164 | t.addCandidate(e.Amount.Commodity, fpath, e.Amount.Span, pf.Src, KindCommodity, prioMarketPrice) |
| 165 | } |
| 166 | |
| 167 | case *ast.DefaultCommodityDirective: |
| 168 | if e.Amount.Commodity != "" { |
| 169 | t.addCandidate(e.Amount.Commodity, fpath, e.Amount.Span, pf.Src, KindCommodity, prioAmountCommodity) |
| 170 | } |
| 171 | |
| 172 | case *ast.ConversionDirective: |
| 173 | if e.From.Commodity != "" { |
| 174 | t.addCandidate(e.From.Commodity, fpath, e.From.Span, pf.Src, KindCommodity, prioAmountCommodity) |
| 175 | } |
| 176 | if e.To.Commodity != "" { |
| 177 | t.addCandidate(e.To.Commodity, fpath, e.To.Span, pf.Src, KindCommodity, prioAmountCommodity) |
| 178 | } |
| 179 | |
| 180 | case *ast.AliasDirective: |
| 181 | t.addCandidate(e.From, fpath, e.Span, pf.Src, KindAccount, prioAliasDirective) |
| 182 | t.addCandidate(e.To, fpath, e.Span, pf.Src, KindAccount, prioAliasDirective) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func (t *Tagger) collectFromPosting(p *ast.Posting, filePath string, src []byte) { |
| 187 | t.addCandidate(p.Account.Name, filePath, p.Account.Span, src, KindAccount, prioAccountPosting) |
| 188 | if p.Amount != nil && p.Amount.Commodity != "" { |
| 189 | t.addCandidate(p.Amount.Commodity, filePath, p.Amount.Span, src, KindCommodity, prioAmountCommodity) |
| 190 | } |
| 191 | if p.Cost != nil && p.Cost.Amount.Commodity != "" { |
| 192 | t.addCandidate(p.Cost.Amount.Commodity, filePath, p.Cost.Amount.Span, src, KindCommodity, prioAmountCommodity) |
| 193 | } |
| 194 | if p.Balance != nil && p.Balance.Amount.Commodity != "" { |
| 195 | t.addCandidate(p.Balance.Amount.Commodity, filePath, p.Balance.Amount.Span, src, KindCommodity, prioAmountCommodity) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func (t *Tagger) addCandidate(name, fpath string, span token.Span, src []byte, kind byte, priority int) { |
| 200 | if name == "" { |
| 201 | return |
| 202 | } |
| 203 | t.candidates = append(t.candidates, candidate{ |
| 204 | name: name, |
| 205 | kind: kind, |
| 206 | file: fpath, |
| 207 | line: span.Start.Line, |
| 208 | priority: priority, |
| 209 | src: src, |
| 210 | offset: span.Start.Offset, |
| 211 | }) |
| 212 | } |
| 213 | |
| 214 | func relativePath(target, base string) string { |
| 215 | rel, err := filepath.Rel(base, target) |
| 216 | if err != nil { |
| 217 | return target |
| 218 | } |
| 219 | return rel |
| 220 | } |
| 221 | |
| 222 | // extractLine returns the source line containing offset. |
| 223 | // Caller guarantees src is non-nil and 0 <= offset < len(src). |
| 224 | func extractLine(src []byte, offset int) string { |
| 225 | start := offset |
| 226 | for start > 0 && src[start-1] != '\n' { |
| 227 | start-- |
| 228 | } |
| 229 | end := offset |
| 230 | for end < len(src) && src[end] != '\n' { |
| 231 | end++ |
| 232 | } |
| 233 | return string(src[start:end]) |
| 234 | } |