hledger-lsp 0.1.1 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +11 -8
  2. package/out/features/codeActions.d.ts +20 -0
  3. package/out/features/codeActions.d.ts.map +1 -1
  4. package/out/features/codeActions.js +145 -0
  5. package/out/features/codeActions.js.map +1 -1
  6. package/out/features/completion.d.ts +16 -4
  7. package/out/features/completion.d.ts.map +1 -1
  8. package/out/features/completion.js +4 -4
  9. package/out/features/completion.js.map +1 -1
  10. package/out/features/definition.js +4 -4
  11. package/out/features/definition.js.map +1 -1
  12. package/out/features/formatter.d.ts +4 -20
  13. package/out/features/formatter.d.ts.map +1 -1
  14. package/out/features/formatter.js +271 -223
  15. package/out/features/formatter.js.map +1 -1
  16. package/out/features/hover.js +4 -4
  17. package/out/features/hover.js.map +1 -1
  18. package/out/features/inlayHints.d.ts +7 -2
  19. package/out/features/inlayHints.d.ts.map +1 -1
  20. package/out/features/inlayHints.js +65 -17
  21. package/out/features/inlayHints.js.map +1 -1
  22. package/out/features/semanticTokens.d.ts.map +1 -1
  23. package/out/features/semanticTokens.js +17 -14
  24. package/out/features/semanticTokens.js.map +1 -1
  25. package/out/features/symbols.js +4 -4
  26. package/out/features/symbols.js.map +1 -1
  27. package/out/features/validator.d.ts +5 -0
  28. package/out/features/validator.d.ts.map +1 -1
  29. package/out/features/validator.js +45 -13
  30. package/out/features/validator.js.map +1 -1
  31. package/out/parser/ast.d.ts +48 -7
  32. package/out/parser/ast.d.ts.map +1 -1
  33. package/out/parser/ast.js +547 -363
  34. package/out/parser/ast.js.map +1 -1
  35. package/out/parser/document.d.ts +7 -0
  36. package/out/parser/document.d.ts.map +1 -0
  37. package/out/parser/document.js +70 -0
  38. package/out/parser/document.js.map +1 -0
  39. package/out/parser/includes.d.ts.map +1 -1
  40. package/out/parser/includes.js +21 -33
  41. package/out/parser/includes.js.map +1 -1
  42. package/out/parser/index.d.ts.map +1 -1
  43. package/out/parser/index.js +39 -7
  44. package/out/parser/index.js.map +1 -1
  45. package/out/server/settings.d.ts +5 -0
  46. package/out/server/settings.d.ts.map +1 -1
  47. package/out/server/settings.js +5 -3
  48. package/out/server/settings.js.map +1 -1
  49. package/out/server.js +4 -4
  50. package/out/server.js.map +1 -1
  51. package/out/types.d.ts +16 -12
  52. package/out/types.d.ts.map +1 -1
  53. package/out/types.js +1 -0
  54. package/out/types.js.map +1 -1
  55. package/out/utils/index.js +1 -1
  56. package/out/utils/index.js.map +1 -1
  57. package/package.json +1 -1
package/out/parser/ast.js CHANGED
@@ -5,10 +5,16 @@ exports.inferCosts = inferCosts;
5
5
  exports.parseTransactionHeader = parseTransactionHeader;
6
6
  exports.parsePosting = parsePosting;
7
7
  exports.parseAmount = parseAmount;
8
- exports.extractAccounts = extractAccounts;
9
- exports.extractPayees = extractPayees;
10
- exports.extractCommodities = extractCommodities;
11
- exports.extractTagNames = extractTagNames;
8
+ exports.parseFormat = parseFormat;
9
+ exports.addAccount = addAccount;
10
+ exports.addPayee = addPayee;
11
+ exports.addCommodity = addCommodity;
12
+ exports.addTag = addTag;
13
+ exports.processAccountDirective = processAccountDirective;
14
+ exports.processPayeeDirective = processPayeeDirective;
15
+ exports.processCommodityDirective = processCommodityDirective;
16
+ exports.processTagDirective = processTagDirective;
17
+ exports.processTransaction = processTransaction;
12
18
  exports.parseDirective = parseDirective;
13
19
  const index_1 = require("../utils/index");
14
20
  /**
@@ -111,11 +117,13 @@ function inferCosts(transaction) {
111
117
  const firstCommodity = transaction.postings[0].amount.commodity || '';
112
118
  // Sum all amounts in the other commodity
113
119
  let otherCommodity = '';
120
+ let otherCommodityFormat;
114
121
  let otherSum = 0;
115
122
  for (const posting of transaction.postings) {
116
123
  const commodity = posting.amount.commodity || '';
117
124
  if (commodity !== firstCommodity) {
118
125
  otherCommodity = commodity;
126
+ otherCommodityFormat = posting.amount.format;
119
127
  otherSum += posting.amount.quantity;
120
128
  }
121
129
  }
@@ -123,7 +131,8 @@ function inferCosts(transaction) {
123
131
  // This makes the transaction balance when cost is used for balance calculation
124
132
  const costAmount = {
125
133
  quantity: -otherSum,
126
- commodity: otherCommodity
134
+ commodity: otherCommodity,
135
+ format: otherCommodityFormat,
127
136
  };
128
137
  // Add inferred total cost to first posting
129
138
  transaction.postings[0].cost = {
@@ -131,60 +140,107 @@ function inferCosts(transaction) {
131
140
  amount: costAmount
132
141
  };
133
142
  }
134
- function parseTransactionHeader(line) {
135
- const trimmed = line.trim();
136
- const dateMatch = trimmed.match(/^(\d{4}[-/]\d{2}[-/]\d{2})/);
137
- if (!dateMatch)
143
+ function parseDate(line) {
144
+ const match = line.match(/^(\d{4}[-/]\d{2}[-/]\d{2})/);
145
+ if (!match)
138
146
  return null;
139
- const date = dateMatch[1];
140
- let rest = trimmed.substring(date.length).trim();
141
- let effectiveDate;
142
- const effectiveDateMatch = rest.match(/^=(\d{4}[-/]\d{2}[-/]\d{2})/);
143
- if (effectiveDateMatch) {
144
- effectiveDate = effectiveDateMatch[1];
145
- rest = rest.substring(effectiveDateMatch[0].length).trim();
146
- }
147
- let status;
148
- if (rest.startsWith('*')) {
149
- status = 'cleared';
150
- rest = rest.substring(1).trim();
151
- }
152
- else if (rest.startsWith('!')) {
153
- status = 'pending';
154
- rest = rest.substring(1).trim();
155
- }
156
- let code;
157
- const codeMatch = rest.match(/^\(([^)]+)\)/);
158
- if (codeMatch) {
159
- code = codeMatch[1];
160
- rest = rest.substring(codeMatch[0].length).trim();
147
+ return { date: match[1], rest: line.substring(match[1].length).trim() };
148
+ }
149
+ function parseEffectiveDate(line) {
150
+ const match = line.match(/^=(\d{4}[-/]\d{2}[-/]\d{2})/);
151
+ if (match) {
152
+ return { effectiveDate: match[1], rest: line.substring(match[0].length).trim() };
153
+ }
154
+ return { effectiveDate: undefined, rest: line };
155
+ }
156
+ function parseStatus(line) {
157
+ if (line.startsWith('*'))
158
+ return { status: 'cleared', rest: line.substring(1).trim() };
159
+ if (line.startsWith('!'))
160
+ return { status: 'pending', rest: line.substring(1).trim() };
161
+ return { status: undefined, rest: line };
162
+ }
163
+ function parseCode(line) {
164
+ const match = line.match(/^\(([^)]+)\)/);
165
+ if (match) {
166
+ return { code: match[1], rest: line.substring(match[0].length).trim() };
161
167
  }
168
+ return { code: undefined, rest: line };
169
+ }
170
+ function parseDescription(line) {
171
+ let description = line;
162
172
  let comment;
163
173
  let tags;
164
- const commentMatch = rest.match(/^([^;]*);(.*)$/);
165
- if (commentMatch) {
166
- rest = commentMatch[1].trim();
167
- comment = commentMatch[2].trim();
174
+ const match = line.match(/^([^;]*);(.*)$/);
175
+ if (match) {
176
+ description = match[1].trim();
177
+ comment = match[2].trim();
168
178
  const extracted = (0, index_1.extractTags)(comment);
169
179
  if (Object.keys(extracted).length > 0)
170
180
  tags = extracted;
171
181
  }
172
- const description = rest.trim();
173
- // Parse payee and note according to hledger spec:
174
- // If description contains |, split into payee (left) and note (right)
175
- // If no |, payee and note both equal description
176
- let payee;
177
- let note;
182
+ else {
183
+ description = line.trim();
184
+ }
185
+ return { description, comment, tags };
186
+ }
187
+ function parsePayeeAndNote(description) {
178
188
  const pipeIndex = description.indexOf('|');
179
189
  if (pipeIndex !== -1) {
180
- payee = description.substring(0, pipeIndex).trim();
181
- note = description.substring(pipeIndex + 1).trim();
190
+ return {
191
+ payee: description.substring(0, pipeIndex).trim(),
192
+ note: description.substring(pipeIndex + 1).trim()
193
+ };
182
194
  }
183
- else {
184
- payee = description;
185
- note = description;
195
+ return { payee: description, note: description };
196
+ }
197
+ function parseTransactionHeader(line) {
198
+ const trimmed = line.trim();
199
+ const dateRes = parseDate(trimmed);
200
+ if (!dateRes)
201
+ return null;
202
+ let rest = dateRes.rest;
203
+ const effDateRes = parseEffectiveDate(rest);
204
+ rest = effDateRes.rest;
205
+ const statusRes = parseStatus(rest);
206
+ rest = statusRes.rest;
207
+ const codeRes = parseCode(rest);
208
+ rest = codeRes.rest;
209
+ const descRes = parseDescription(rest);
210
+ const { payee, note } = parsePayeeAndNote(descRes.description);
211
+ return {
212
+ date: dateRes.date,
213
+ effectiveDate: effDateRes.effectiveDate,
214
+ status: statusRes.status,
215
+ code: codeRes.code,
216
+ description: descRes.description,
217
+ payee,
218
+ note,
219
+ comment: descRes.comment,
220
+ tags: descRes.tags
221
+ };
222
+ }
223
+ function parseCost(text) {
224
+ // Check for @@ first (total price), then @ (unit price)
225
+ const totalCostMatch = text.match(/@@\s*(.+)$/);
226
+ const unitCostMatch = !totalCostMatch ? text.match(/@\s*(.+)$/) : null;
227
+ if (totalCostMatch) {
228
+ const amountPart = text.substring(0, totalCostMatch.index ?? 0).trim();
229
+ const costPart = totalCostMatch[1].trim();
230
+ const costAmount = parseAmount(costPart);
231
+ if (costAmount) {
232
+ return { amountPart, cost: { type: 'total', amount: costAmount } };
233
+ }
186
234
  }
187
- return { date, effectiveDate, status, code, description, payee, note, comment, tags };
235
+ else if (unitCostMatch) {
236
+ const amountPart = text.substring(0, unitCostMatch.index ?? 0).trim();
237
+ const costPart = unitCostMatch[1].trim();
238
+ const costAmount = parseAmount(costPart);
239
+ if (costAmount) {
240
+ return { amountPart, cost: { type: 'unit', amount: costAmount } };
241
+ }
242
+ }
243
+ return { amountPart: text.trim() };
188
244
  }
189
245
  function parsePosting(line) {
190
246
  if (!(0, index_1.isPosting)(line))
@@ -221,365 +277,493 @@ function parsePosting(line) {
221
277
  posting.assertion = assertionAmount;
222
278
  }
223
279
  // Now parse amount and cost from beforeAssertion
224
- // Check for @@ first (total price), then @ (unit price)
225
- const totalCostMatch = beforeAssertion.match(/@@\s*(.+)$/);
226
- const unitCostMatch = !totalCostMatch ? beforeAssertion.match(/@\s*(.+)$/) : null;
227
- if (totalCostMatch) {
228
- // Parse total cost: amount @@ totalCost
229
- const amountPart = beforeAssertion.substring(0, totalCostMatch.index ?? 0).trim();
230
- const costPart = totalCostMatch[1].trim();
231
- const amount = parseAmount(amountPart);
232
- if (amount)
233
- posting.amount = amount;
234
- const costAmount = parseAmount(costPart);
235
- if (costAmount) {
236
- posting.cost = { type: 'total', amount: costAmount };
280
+ const { amountPart, cost } = parseCost(beforeAssertion);
281
+ if (cost)
282
+ posting.cost = cost;
283
+ const amount = parseAmount(amountPart);
284
+ if (amount)
285
+ posting.amount = amount;
286
+ return posting;
287
+ }
288
+ /**
289
+ * Helper to detect decimal mark and thousands separator from a number string.
290
+ */
291
+ function detectNumberFormat(numStr, defaultDecimalMark) {
292
+ let decimalMark = defaultDecimalMark;
293
+ if (!decimalMark) {
294
+ const lastDot = numStr.lastIndexOf('.');
295
+ const lastComma = numStr.lastIndexOf(',');
296
+ if (lastDot > -1 && lastComma > -1) {
297
+ decimalMark = lastDot > lastComma ? '.' : ',';
298
+ }
299
+ else if (lastDot > -1) {
300
+ // Check if it's a thousands separator
301
+ const parts = numStr.split('.');
302
+ if (parts.length > 2) {
303
+ decimalMark = null; // Multiple dots -> thousands separator
304
+ }
305
+ else {
306
+ decimalMark = '.';
307
+ }
308
+ }
309
+ else if (lastComma > -1) {
310
+ const parts = numStr.split(',');
311
+ if (parts.length > 2) {
312
+ decimalMark = null; // Multiple commas -> thousands separator
313
+ }
314
+ else {
315
+ decimalMark = ',';
316
+ }
317
+ }
318
+ else {
319
+ decimalMark = null;
237
320
  }
238
321
  }
239
- else if (unitCostMatch) {
240
- // Parse unit cost: amount @ unitCost
241
- const amountPart = beforeAssertion.substring(0, unitCostMatch.index ?? 0).trim();
242
- const costPart = unitCostMatch[1].trim();
243
- const amount = parseAmount(amountPart);
244
- if (amount)
245
- posting.amount = amount;
246
- const costAmount = parseAmount(costPart);
247
- if (costAmount) {
248
- posting.cost = { type: 'unit', amount: costAmount };
322
+ // Detect thousands separator
323
+ // It should be the other separator if present
324
+ // We need to look at the integer part only
325
+ let integerPart = numStr;
326
+ if (decimalMark) {
327
+ const lastIndex = numStr.lastIndexOf(decimalMark);
328
+ if (lastIndex >= 0) {
329
+ integerPart = numStr.substring(0, lastIndex);
330
+ }
331
+ }
332
+ const sepCounts = {};
333
+ for (let i = 0; i < integerPart.length; i++) {
334
+ const ch = integerPart[i];
335
+ if (ch < '0' || ch > '9')
336
+ sepCounts[ch] = (sepCounts[ch] || 0) + 1;
337
+ }
338
+ let thousandsSeparator = null;
339
+ const separators = Object.keys(sepCounts);
340
+ // If we have a decimal mark, the thousands separator must be different
341
+ // Filter out the decimal mark from potential separators just in case
342
+ const potentialSeparators = separators.filter(s => s !== decimalMark);
343
+ if (potentialSeparators.length === 1) {
344
+ thousandsSeparator = potentialSeparators[0];
345
+ }
346
+ else if (potentialSeparators.length > 1) {
347
+ let max = 0;
348
+ let pick = null;
349
+ for (const k of potentialSeparators) {
350
+ if (sepCounts[k] > max) {
351
+ max = sepCounts[k];
352
+ pick = k;
353
+ }
354
+ }
355
+ thousandsSeparator = pick;
356
+ }
357
+ return { decimalMark, thousandsSeparator };
358
+ }
359
+ /**
360
+ * Helper to parse number string given a decimal mark
361
+ */
362
+ function parseNumberWithFormat(numStr, mark) {
363
+ if (!mark) {
364
+ // If no mark provided/detected, assume standard float parsing (remove all non-digits/dots/minus)
365
+ // But wait, if no mark, we need to decide what to do.
366
+ // If ambiguous (e.g. 1.000), hledger assumes decimal mark.
367
+ // So we should treat the last separator as decimal if it exists.
368
+ const lastDot = numStr.lastIndexOf('.');
369
+ const lastComma = numStr.lastIndexOf(',');
370
+ if (lastDot > -1 && lastComma > -1) {
371
+ mark = lastDot > lastComma ? '.' : ',';
372
+ }
373
+ else if (lastDot > -1) {
374
+ mark = '.';
375
+ }
376
+ else if (lastComma > -1) {
377
+ mark = ',';
378
+ }
379
+ }
380
+ let cleanStr = numStr;
381
+ if (mark) {
382
+ // Remove everything that is NOT the decimal mark, digit, or minus
383
+ // This effectively removes thousands separators
384
+ const regex = new RegExp(`[^0-9${mark === '.' ? '\\.' : ','}-]`, 'g');
385
+ cleanStr = numStr.replace(regex, '');
386
+ // Replace decimal mark with dot for JS parseFloat
387
+ if (mark === ',') {
388
+ cleanStr = cleanStr.replace(',', '.');
249
389
  }
250
390
  }
251
391
  else {
252
- // No cost notation, just parse amount
253
- const amount = parseAmount(beforeAssertion);
254
- if (amount)
255
- posting.amount = amount;
392
+ // No separators found, just parse
393
+ cleanStr = numStr.replace(/[^0-9.-]/g, '');
256
394
  }
257
- return posting;
395
+ return parseFloat(cleanStr);
258
396
  }
259
- function parseAmount(amountStr) {
397
+ function parseAmount(amountStr, decimalMark) {
260
398
  const trimmed = amountStr.trim();
261
399
  if (!trimmed)
262
400
  return null;
401
+ // Regex to split commodity and amount
402
+ // We need to be more permissive with the amount part to capture various formats
403
+ // Amount part can contain digits, commas, dots, spaces (maybe)
404
+ // But spaces are tricky. For now let's assume space separates commodity if symbol is on left/right
263
405
  const patterns = [
264
- { pattern: /^-([^\d\s-]+)\s*(\d+(?:[,]\d{3})*(?:\.\d+)?)$/, handler: (m) => ({ quantity: -parseFloat(m[2].replace(/,/g, '')), commodity: m[1] }) },
265
- { pattern: /^([^\d\s-]+)\s*([-]?\d+(?:[,]\d{3})*(?:\.\d+)?)$/, handler: (m) => ({ quantity: parseFloat(m[2].replace(/,/g, '')), commodity: m[1] }) },
266
- { pattern: /^([-]?\d+(?:[,]\d{3})*(?:\.\d+)?)\s*([^\d\s]+)$/, handler: (m) => ({ quantity: parseFloat(m[1].replace(/,/g, '')), commodity: m[2] }) },
267
- { pattern: /^([-]?\d+(?:[,]\d{3})*(?:\.\d+)?)$/, handler: (m) => ({ quantity: parseFloat(m[1].replace(/,/g, '')), commodity: '' }) }
406
+ {
407
+ // Symbol on left, negative
408
+ pattern: /^-([^\d\s-]+)\s*([-]?\d[\d.,\s]*)$/,
409
+ handler: (m) => {
410
+ const rawAmount = m[2];
411
+ const { decimalMark: mark } = detectNumberFormat(rawAmount, decimalMark);
412
+ return { quantity: -Math.abs(parseNumberWithFormat(rawAmount, mark || undefined)), commodity: m[1], rawAmount };
413
+ },
414
+ cleaner: (m, s) => s.replace(/^-/, '')
415
+ },
416
+ {
417
+ // Symbol on left
418
+ pattern: /^([^\d\s-]+)\s*([-]?\d[\d.,\s]*)$/,
419
+ handler: (m) => {
420
+ const rawAmount = m[2];
421
+ const { decimalMark: mark } = detectNumberFormat(rawAmount, decimalMark);
422
+ return { quantity: parseNumberWithFormat(rawAmount, mark || undefined), commodity: m[1], rawAmount };
423
+ },
424
+ cleaner: (m, s) => s.replace(m[2], m[2].replace('-', ''))
425
+ },
426
+ {
427
+ // Symbol on right
428
+ pattern: /^([-]?\d[\d.,\s]*)\s*([^\d\s]+)$/,
429
+ handler: (m) => {
430
+ const rawAmount = m[1];
431
+ const { decimalMark: mark } = detectNumberFormat(rawAmount, decimalMark);
432
+ return { quantity: parseNumberWithFormat(rawAmount, mark || undefined), commodity: m[2], rawAmount };
433
+ },
434
+ cleaner: (m, s) => s.replace(m[1], m[1].replace('-', ''))
435
+ },
436
+ {
437
+ // No symbol
438
+ pattern: /^([-]?\d[\d.,\s]*)$/,
439
+ handler: (m) => {
440
+ const rawAmount = m[1];
441
+ const { decimalMark: mark } = detectNumberFormat(rawAmount, decimalMark);
442
+ return { quantity: parseNumberWithFormat(rawAmount, mark || undefined), commodity: '', rawAmount };
443
+ },
444
+ cleaner: (m, s) => s.replace(m[1], m[1].replace('-', ''))
445
+ }
268
446
  ];
269
- for (const { pattern, handler } of patterns) {
447
+ for (const { pattern, handler, cleaner } of patterns) {
270
448
  const match = trimmed.match(pattern);
271
449
  if (match) {
450
+ // Verify that the amount part looks valid (e.g. not just a dot)
451
+ // and doesn't contain internal spaces that look like commodity separators
452
+ // This is a heuristic.
272
453
  const res = handler(match);
273
454
  if (isNaN(res.quantity))
274
- return null;
275
- return { quantity: res.quantity, commodity: res.commodity };
455
+ continue;
456
+ const amount = { quantity: res.quantity, commodity: res.commodity };
457
+ let sampleForFormat = trimmed;
458
+ if (res.quantity < 0) {
459
+ sampleForFormat = cleaner(match, trimmed);
460
+ }
461
+ const parsedFormat = parseFormat(sampleForFormat);
462
+ if (parsedFormat && parsedFormat.format) {
463
+ amount.format = parsedFormat.format;
464
+ }
465
+ return amount;
276
466
  }
277
467
  }
278
468
  return null;
279
469
  }
280
- function extractAccounts(document, sourceUri) {
281
- const text = document.getText();
282
- const lines = text.split('\n');
283
- const accountMap = new Map();
284
- for (let i = 0; i < lines.length; i++) {
285
- const line = lines[i];
286
- if (line.trim().startsWith('account ')) {
287
- const accountName = line.trim().substring(8).split(';')[0].trim();
288
- if (accountName) {
289
- const acc = { name: accountName, declared: true };
290
- if (sourceUri !== undefined) {
291
- acc.sourceUri = sourceUri;
292
- acc.line = i;
293
- }
294
- accountMap.set(accountName, acc);
295
- }
470
+ function parseFormat(sample) {
471
+ if (!sample)
472
+ return null;
473
+ const s = sample.trim();
474
+ const stripQuotes = (s) => { const t = s.trim(); if (t.length >= 2 && t.startsWith('"') && t.endsWith('"'))
475
+ return t.substring(1, t.length - 1); return t; };
476
+ const firstDigit = s.search(/\d/);
477
+ if (firstDigit === -1) {
478
+ return { name: stripQuotes(s) };
479
+ }
480
+ const allowed = /[0-9.,\u00A0\s]/;
481
+ let start = firstDigit;
482
+ let end = start;
483
+ while (end < s.length && allowed.test(s[end]))
484
+ end++;
485
+ const leftRaw = s.substring(0, start).trim();
486
+ const numberRaw = s.substring(start, end).trim();
487
+ const rightRaw = s.substring(end).trim();
488
+ if (!numberRaw)
489
+ return null;
490
+ const { decimalMark, thousandsSeparator } = detectNumberFormat(numberRaw);
491
+ let decimalIndex = -1;
492
+ if (decimalMark) {
493
+ decimalIndex = numberRaw.lastIndexOf(decimalMark);
494
+ }
495
+ const fractionalPart = decimalIndex >= 0 ? numberRaw.substring(decimalIndex + 1) : '';
496
+ const precision = decimalIndex >= 0 ? (fractionalPart.length > 0 ? fractionalPart.length : 0) : null;
497
+ let rawSymbol = leftRaw || rightRaw || '';
498
+ rawSymbol = stripQuotes(rawSymbol);
499
+ const symbolOnLeft = Boolean(leftRaw);
500
+ let spaceBetween = false;
501
+ if (symbolOnLeft) {
502
+ const between = s.substring(0, firstDigit);
503
+ spaceBetween = /\s/.test(between.replace(stripQuotes(leftRaw), '')) || /\s/.test(leftRaw.slice(-1));
504
+ }
505
+ else {
506
+ const after = s.substring(end);
507
+ spaceBetween = /\s/.test(after.replace(stripQuotes(rightRaw), '')) || /\s/.test(s[end - 1]);
508
+ }
509
+ const format = { symbol: rawSymbol, symbolOnLeft, spaceBetween, decimalMark: decimalMark, thousandsSeparator: thousandsSeparator || null, precision };
510
+ let name = rawSymbol;
511
+ if (rightRaw) {
512
+ const candidate = stripQuotes(rightRaw);
513
+ if (/^[A-Za-z][A-Za-z0-9 _-]*$/.test(candidate) || (rightRaw.trim().startsWith('"') && rightRaw.trim().endsWith('"')))
514
+ name = candidate;
515
+ }
516
+ if (rawSymbol === '""')
517
+ name = '';
518
+ return { name, format };
519
+ }
520
+ /**
521
+ * Helper functions used by processCommodityDirective
522
+ */
523
+ const stripQuotes = (s) => { const t = s.trim(); if (t.length >= 2 && t.startsWith('"') && t.endsWith('"'))
524
+ return t.substring(1, t.length - 1); return t; };
525
+ function parseCommodityDirective(line) {
526
+ const directive = line.trim().substring(10).split(';')[0].trim();
527
+ if (!directive)
528
+ return null;
529
+ let parsed = null;
530
+ if (/\d/.test(directive))
531
+ parsed = parseFormat(directive);
532
+ if (parsed) {
533
+ return { name: parsed.name, format: parsed.format };
534
+ }
535
+ else {
536
+ return { name: stripQuotes(directive), format: undefined };
537
+ }
538
+ }
539
+ function parseFormatSubDirective(line) {
540
+ const trimmedNext = line.trim();
541
+ if (!trimmedNext.startsWith('format '))
542
+ return null;
543
+ const rest = trimmedNext.substring(7).trim();
544
+ const m = rest.match(/^(".*?"|\S+)\s+(.*)$/);
545
+ if (m) {
546
+ const formatSymbolRaw = m[1];
547
+ const samplePart = m[2];
548
+ const parsedFormat = parseFormat(samplePart) || parseFormat(`${samplePart} ${formatSymbolRaw}`);
549
+ if (parsedFormat && parsedFormat.format) {
550
+ const fs = stripQuotes(formatSymbolRaw);
551
+ return { name: fs, format: parsedFormat.format };
296
552
  }
297
- if ((0, index_1.isPosting)(line)) {
298
- const account = (0, index_1.extractAccountFromPosting)(line);
299
- if (account && !accountMap.has(account)) {
300
- const acc = { name: account, declared: false };
301
- if (sourceUri !== undefined) {
302
- acc.sourceUri = sourceUri;
303
- acc.line = i;
304
- }
305
- accountMap.set(account, acc);
306
- }
553
+ }
554
+ // If no match, try parsing rest directly as a format sample (e.g., "format $1,000.00")
555
+ const parsedDirect = parseFormat(rest);
556
+ if (parsedDirect && parsedDirect.format) {
557
+ return { name: parsedDirect.name, format: parsedDirect.format };
558
+ }
559
+ return null;
560
+ }
561
+ /**
562
+ * Helper functions for incremental parsing - adding items to Maps
563
+ */
564
+ /**
565
+ * Add or update an account in the accounts map
566
+ * If account exists, mark as declared if it wasn't already
567
+ */
568
+ function addAccount(accountMap, name, declared, sourceUri, line) {
569
+ const existing = accountMap.get(name);
570
+ if (existing) {
571
+ // If we're adding a declared version, update the existing entry
572
+ if (declared && !existing.declared) {
573
+ existing.declared = true;
574
+ if (sourceUri !== undefined)
575
+ existing.sourceUri = sourceUri;
576
+ if (line !== undefined)
577
+ existing.line = line;
307
578
  }
308
579
  }
309
- return Array.from(accountMap.values()).sort((a, b) => a.name.localeCompare(b.name));
580
+ else {
581
+ const acc = { name, declared };
582
+ if (sourceUri !== undefined) {
583
+ acc.sourceUri = sourceUri;
584
+ acc.line = line;
585
+ }
586
+ accountMap.set(name, acc);
587
+ }
310
588
  }
311
- function extractPayees(document, sourceUri) {
312
- const text = document.getText();
313
- const lines = text.split('\n');
314
- const payeeMap = new Map();
315
- for (let i = 0; i < lines.length; i++) {
316
- const line = lines[i];
317
- if (line.trim().startsWith('payee ')) {
318
- const payeeName = line.trim().substring(6).split(';')[0].trim();
319
- if (payeeName) {
320
- const p = { name: payeeName, declared: true };
321
- if (sourceUri !== undefined) {
322
- p.sourceUri = sourceUri;
323
- p.line = i;
324
- }
325
- payeeMap.set(payeeName, p);
326
- }
589
+ /**
590
+ * Add or update a payee in the payees map
591
+ */
592
+ function addPayee(payeeMap, name, declared, sourceUri, line) {
593
+ const existing = payeeMap.get(name);
594
+ if (existing) {
595
+ if (declared && !existing.declared) {
596
+ existing.declared = true;
597
+ if (sourceUri !== undefined)
598
+ existing.sourceUri = sourceUri;
599
+ if (line !== undefined)
600
+ existing.line = line;
327
601
  }
328
- if ((0, index_1.isTransactionHeader)(line)) {
329
- const header = parseTransactionHeader(line);
330
- // Use payee field instead of description (handles | splitting)
331
- if (header && header.payee && !payeeMap.has(header.payee)) {
332
- const p = { name: header.payee, declared: false };
333
- if (sourceUri !== undefined) {
334
- p.sourceUri = sourceUri;
335
- p.line = i;
336
- }
337
- payeeMap.set(header.payee, p);
338
- }
602
+ }
603
+ else {
604
+ const p = { name, declared };
605
+ if (sourceUri !== undefined) {
606
+ p.sourceUri = sourceUri;
607
+ p.line = line;
339
608
  }
609
+ payeeMap.set(name, p);
340
610
  }
341
- return Array.from(payeeMap.values()).sort((a, b) => a.name.localeCompare(b.name));
342
611
  }
343
- function extractCommodities(document, sourceUri) {
344
- const text = document.getText();
345
- const lines = text.split('\n');
346
- const commodityMap = new Map();
347
- const stripQuotes = (s) => { const t = s.trim(); if (t.length >= 2 && t.startsWith('"') && t.endsWith('"'))
348
- return t.substring(1, t.length - 1); return t; };
349
- const parseCommoditySample = (sample) => {
350
- if (!sample)
351
- return null;
352
- const s = sample.trim();
353
- const firstDigit = s.search(/\d/);
354
- if (firstDigit === -1)
355
- return null;
356
- const allowed = /[0-9.,\u00A0\s]/;
357
- let start = firstDigit;
358
- let end = start;
359
- while (end < s.length && allowed.test(s[end]))
360
- end++;
361
- const leftRaw = s.substring(0, start).trim();
362
- const numberRaw = s.substring(start, end).trim();
363
- const rightRaw = s.substring(end).trim();
364
- if (!numberRaw)
365
- return null;
366
- const lastDot = numberRaw.lastIndexOf('.');
367
- const lastComma = numberRaw.lastIndexOf(',');
368
- let decimalMark;
369
- let decimalIndex = -1;
370
- if (lastDot === -1 && lastComma === -1)
371
- decimalMark = undefined;
372
- else if (lastDot > lastComma) {
373
- decimalMark = '.';
374
- decimalIndex = lastDot;
612
+ /**
613
+ * Add or update a commodity in the commodities map
614
+ * When merging formats, prefer the one with higher precision or more detail
615
+ */
616
+ function addCommodity(commodityMap, name, declared, format, sourceUri, line) {
617
+ const existing = commodityMap.get(name);
618
+ if (existing) {
619
+ if (declared) {
620
+ existing.declared = true;
621
+ if (format)
622
+ existing.format = format;
623
+ if (sourceUri !== undefined)
624
+ existing.sourceUri = sourceUri;
625
+ if (line !== undefined)
626
+ existing.line = line;
375
627
  }
376
- else {
377
- decimalMark = ',';
378
- decimalIndex = lastComma;
379
- }
380
- const integerPart = decimalIndex >= 0 ? numberRaw.substring(0, decimalIndex) : numberRaw;
381
- const fractionalPart = decimalIndex >= 0 ? numberRaw.substring(decimalIndex + 1) : '';
382
- const precision = decimalIndex >= 0 ? (fractionalPart.length > 0 ? fractionalPart.length : 0) : null;
383
- const sepCounts = {};
384
- for (let i = 0; i < integerPart.length; i++) {
385
- const ch = integerPart[i];
386
- if (ch < '0' || ch > '9')
387
- sepCounts[ch] = (sepCounts[ch] || 0) + 1;
388
- }
389
- let thousandsSeparator = null;
390
- const separators = Object.keys(sepCounts);
391
- if (separators.length === 1)
392
- thousandsSeparator = separators[0];
393
- else if (separators.length > 1) {
394
- let max = 0;
395
- let pick = null;
396
- for (const k of separators) {
397
- if (sepCounts[k] > max) {
398
- max = sepCounts[k];
399
- pick = k;
400
- }
628
+ else if (format && !existing.declared) {
629
+ // For undeclared commodities, keep the format with better precision
630
+ const newPrecision = format.precision ?? null;
631
+ const existingPrecision = existing.format?.precision ?? null;
632
+ if (!existing.format || (newPrecision !== null && (existingPrecision === null || newPrecision > existingPrecision))) {
633
+ existing.format = format;
401
634
  }
402
- thousandsSeparator = pick;
403
635
  }
404
- let rawSymbol = leftRaw || rightRaw || '';
405
- rawSymbol = stripQuotes(rawSymbol);
406
- const symbolOnLeft = Boolean(leftRaw);
407
- let spaceBetween = false;
408
- if (symbolOnLeft) {
409
- const between = s.substring(0, firstDigit);
410
- spaceBetween = /\s/.test(between.replace(stripQuotes(leftRaw), '')) || /\s/.test(leftRaw.slice(-1));
636
+ }
637
+ else {
638
+ const c = { name, declared, format };
639
+ if (sourceUri !== undefined) {
640
+ c.sourceUri = sourceUri;
641
+ c.line = line;
411
642
  }
412
- else {
413
- const after = s.substring(end);
414
- spaceBetween = /\s/.test(after.replace(stripQuotes(rightRaw), '')) || /\s/.test(s[end - 1]);
415
- }
416
- const format = { symbol: rawSymbol, symbolOnLeft, spaceBetween, decimalMark: decimalMark, thousandsSeparator: thousandsSeparator || null, precision };
417
- let name = rawSymbol;
418
- if (rightRaw) {
419
- const candidate = stripQuotes(rightRaw);
420
- if (/^[A-Za-z][A-Za-z0-9 _-]*$/.test(candidate) || (rightRaw.trim().startsWith('"') && rightRaw.trim().endsWith('"')))
421
- name = candidate;
422
- }
423
- if (rawSymbol === '""')
424
- name = '';
425
- return { name, format };
426
- };
427
- for (let i = 0; i < lines.length; i++) {
428
- const line = lines[i];
429
- if (line.trim().startsWith('commodity ')) {
430
- const directive = line.trim().substring(10).split(';')[0].trim();
431
- if (!directive)
432
- continue;
433
- let parsed = null;
434
- if (/\d/.test(directive))
435
- parsed = parseCommoditySample(directive);
436
- let commodityName;
437
- let format;
438
- if (parsed) {
439
- commodityName = parsed.name;
440
- format = parsed.format;
441
- }
442
- else {
443
- commodityName = stripQuotes(directive);
444
- format = undefined;
445
- }
446
- let look = i + 1;
447
- while (look < lines.length) {
448
- const next = lines[look];
449
- if (!next.trim()) {
450
- look++;
451
- continue;
452
- }
453
- if (!/^\s+/.test(next))
454
- break;
455
- const trimmedNext = next.trim();
456
- if (trimmedNext.startsWith('format ')) {
457
- const rest = trimmedNext.substring(7).trim();
458
- const m = rest.match(/^(".*?"|\S+)\s+(.*)$/);
459
- if (m) {
460
- const formatSymbolRaw = m[1];
461
- const samplePart = m[2];
462
- const parsedFormat = parseCommoditySample(samplePart) || parseCommoditySample(`${samplePart} ${formatSymbolRaw}`);
463
- if (parsedFormat && parsedFormat.format) {
464
- format = parsedFormat.format;
465
- const fs = stripQuotes(formatSymbolRaw);
466
- if (fs && fs !== '' && (!commodityName || commodityName === '' || commodityName === fs))
467
- commodityName = fs;
468
- }
469
- }
470
- }
471
- look++;
472
- }
473
- const key = commodityName;
474
- if (commodityMap.has(key)) {
475
- const existing = commodityMap.get(key);
476
- const merged = { ...existing, declared: existing.declared || true, format: existing.format || format };
477
- if (sourceUri !== undefined) {
478
- merged.sourceUri = existing.sourceUri || sourceUri;
479
- merged.line = existing.line ?? i;
480
- }
481
- commodityMap.set(key, merged);
482
- }
483
- else {
484
- const c = { name: commodityName, declared: true, format };
485
- if (sourceUri !== undefined) {
486
- c.sourceUri = sourceUri;
487
- c.line = i;
488
- }
489
- commodityMap.set(key, c);
490
- }
643
+ commodityMap.set(name, c);
644
+ }
645
+ }
646
+ /**
647
+ * Add or update a tag in the tags map
648
+ */
649
+ function addTag(tagMap, name, declared, sourceUri, line) {
650
+ const existing = tagMap.get(name);
651
+ if (existing) {
652
+ if (declared && !existing.declared) {
653
+ existing.declared = true;
654
+ if (sourceUri !== undefined)
655
+ existing.sourceUri = sourceUri;
656
+ if (line !== undefined)
657
+ existing.line = line;
491
658
  }
492
- if ((0, index_1.isPosting)(line)) {
493
- const posting = parsePosting(line);
494
- if (posting?.amount?.commodity && posting.amount.commodity !== '') {
495
- const key = posting.amount.commodity;
496
- if (!commodityMap.has(key)) {
497
- const c = { name: key, declared: false };
498
- if (sourceUri !== undefined) {
499
- c.sourceUri = sourceUri;
500
- }
501
- commodityMap.set(key, c);
502
- }
503
- }
504
- // Also extract commodity from cost notation
505
- if (posting?.cost?.amount?.commodity && posting.cost.amount.commodity !== '') {
506
- const key = posting.cost.amount.commodity;
507
- if (!commodityMap.has(key)) {
508
- const c = { name: key, declared: false };
509
- if (sourceUri !== undefined) {
510
- c.sourceUri = sourceUri;
511
- }
512
- commodityMap.set(key, c);
513
- }
514
- }
659
+ }
660
+ else {
661
+ const t = { name, declared };
662
+ if (sourceUri !== undefined) {
663
+ t.sourceUri = sourceUri;
664
+ t.line = line;
515
665
  }
666
+ tagMap.set(name, t);
516
667
  }
517
- return Array.from(commodityMap.values()).sort((a, b) => a.name.localeCompare(b.name));
518
668
  }
519
- function extractTagNames(document, sourceUri) {
520
- const text = document.getText();
521
- const lines = text.split('\n');
522
- const tagMap = new Map();
523
- for (let i = 0; i < lines.length; i++) {
524
- const line = lines[i];
525
- if (line.trim().startsWith('tag ')) {
526
- const tagName = line.trim().substring(4).split(';')[0].trim();
527
- if (tagName) {
528
- const t = { name: tagName, declared: true };
529
- if (sourceUri !== undefined) {
530
- t.sourceUri = sourceUri;
531
- t.line = i;
532
- }
533
- tagMap.set(tagName, t);
534
- }
669
+ /**
670
+ * Process an account directive and add it to the accounts map
671
+ */
672
+ function processAccountDirective(line, accountMap, sourceUri, lineNumber) {
673
+ const accountName = line.trim().substring(8).split(';')[0].trim();
674
+ if (accountName) {
675
+ addAccount(accountMap, accountName, true, sourceUri, lineNumber);
676
+ }
677
+ }
678
+ /**
679
+ * Process a payee directive and add it to the payees map
680
+ */
681
+ function processPayeeDirective(line, payeeMap, sourceUri, lineNumber) {
682
+ const payeeName = line.trim().substring(6).split(';')[0].trim();
683
+ if (payeeName) {
684
+ addPayee(payeeMap, payeeName, true, sourceUri, lineNumber);
685
+ }
686
+ }
687
+ /**
688
+ * Process a commodity directive and add it to the commodities map
689
+ * Handles multi-line commodity directives with format subdirectives
690
+ */
691
+ function processCommodityDirective(lines, startLine, commodityMap, sourceUri) {
692
+ const line = lines[startLine];
693
+ const parsed = parseCommodityDirective(line);
694
+ if (!parsed)
695
+ return startLine;
696
+ let commodityName = parsed.name;
697
+ let format = parsed.format;
698
+ // Check for format subdirective on following lines
699
+ let look = startLine + 1;
700
+ while (look < lines.length) {
701
+ const next = lines[look];
702
+ if (!next.trim()) {
703
+ look++;
704
+ continue;
535
705
  }
536
- // Standalone or indented comment lines (transaction-level comments)
537
- if ((0, index_1.isComment)(line)) {
538
- const commentText = line.trim().substring(1);
539
- const extracted = (0, index_1.extractTags)(commentText);
540
- for (const k of Object.keys(extracted)) {
541
- if (!tagMap.has(k)) {
542
- const t = { name: k, declared: false };
543
- if (sourceUri !== undefined) {
544
- t.sourceUri = sourceUri;
545
- t.line = i;
546
- }
547
- tagMap.set(k, t);
548
- }
706
+ if (!/^\s+/.test(next))
707
+ break;
708
+ const subParsed = parseFormatSubDirective(next);
709
+ if (subParsed) {
710
+ if (subParsed.format)
711
+ format = subParsed.format;
712
+ if (subParsed.name && subParsed.name !== '' && (!commodityName || commodityName === '' || commodityName === subParsed.name)) {
713
+ commodityName = subParsed.name;
549
714
  }
550
715
  }
551
- if ((0, index_1.isPosting)(line)) {
552
- // Use parsePosting to extract tags from the posting's comment part only
553
- const posting = parsePosting(line);
554
- if (posting?.tags) {
555
- for (const k of Object.keys(posting.tags)) {
556
- if (!tagMap.has(k)) {
557
- const t = { name: k, declared: false };
558
- if (sourceUri !== undefined) {
559
- t.sourceUri = sourceUri;
560
- }
561
- tagMap.set(k, t);
562
- }
563
- }
564
- }
716
+ look++;
717
+ }
718
+ addCommodity(commodityMap, commodityName, true, format, sourceUri, startLine);
719
+ // Return the last line we processed
720
+ return look - 1;
721
+ }
722
+ /**
723
+ * Process a tag directive and add it to the tags map
724
+ */
725
+ function processTagDirective(line, tagMap, sourceUri, lineNumber) {
726
+ const tagName = line.trim().substring(4).split(';')[0].trim();
727
+ if (tagName) {
728
+ addTag(tagMap, tagName, true, sourceUri, lineNumber);
729
+ }
730
+ }
731
+ /**
732
+ * Extract accounts, commodities, tags from a transaction and add them to the maps
733
+ */
734
+ function processTransaction(transaction, accountMap, commodityMap, tagMap, sourceUri) {
735
+ // Extract payee is handled separately since it's in the transaction header
736
+ // Extract accounts and commodities from postings
737
+ for (const posting of transaction.postings) {
738
+ // Add account
739
+ if (posting.account) {
740
+ addAccount(accountMap, posting.account, false, sourceUri);
565
741
  }
566
- if ((0, index_1.isTransactionHeader)(line)) {
567
- const m = line.match(/;(.+)$/);
568
- if (m) {
569
- const extracted = (0, index_1.extractTags)(m[1]);
570
- for (const k of Object.keys(extracted)) {
571
- if (!tagMap.has(k)) {
572
- const t = { name: k, declared: false };
573
- if (sourceUri !== undefined) {
574
- t.sourceUri = sourceUri;
575
- }
576
- tagMap.set(k, t);
577
- }
578
- }
742
+ // Add commodity from amount
743
+ if (posting.amount?.commodity && posting.amount.commodity !== '') {
744
+ addCommodity(commodityMap, posting.amount.commodity, false, undefined, sourceUri);
745
+ }
746
+ // Add commodity from cost
747
+ if (posting.cost?.amount?.commodity && posting.cost.amount.commodity !== '') {
748
+ addCommodity(commodityMap, posting.cost.amount.commodity, false, undefined, sourceUri);
749
+ }
750
+ // Add commodity from balance assertion
751
+ if (posting.assertion?.commodity && posting.assertion.commodity !== '') {
752
+ addCommodity(commodityMap, posting.assertion.commodity, false, undefined, sourceUri);
753
+ }
754
+ // Extract tags from posting comments
755
+ if (posting.tags) {
756
+ for (const tagName of Object.keys(posting.tags)) {
757
+ addTag(tagMap, tagName, false, sourceUri);
579
758
  }
580
759
  }
581
760
  }
582
- return Array.from(tagMap.values()).sort((a, b) => a.name.localeCompare(b.name));
761
+ // Extract tags from transaction-level tags
762
+ if (transaction.tags) {
763
+ for (const tagName of Object.keys(transaction.tags)) {
764
+ addTag(tagMap, tagName, false, sourceUri);
765
+ }
766
+ }
583
767
  }
584
768
  function parseDirective(line) {
585
769
  const trimmed = line.trim();