hledger-lsp 0.1.5 → 0.1.7
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.
- package/README.md +87 -167
- package/out/features/codeActions.d.ts +0 -4
- package/out/features/codeActions.d.ts.map +1 -1
- package/out/features/codeActions.js +11 -25
- package/out/features/codeActions.js.map +1 -1
- package/out/features/codeLens.d.ts +29 -0
- package/out/features/codeLens.d.ts.map +1 -0
- package/out/features/codeLens.js +105 -0
- package/out/features/codeLens.js.map +1 -0
- package/out/features/completion.d.ts.map +1 -1
- package/out/features/completion.js +21 -9
- package/out/features/completion.js.map +1 -1
- package/out/features/definition.d.ts.map +1 -1
- package/out/features/definition.js.map +1 -1
- package/out/features/foldingRanges.d.ts.map +1 -1
- package/out/features/foldingRanges.js +9 -2
- package/out/features/foldingRanges.js.map +1 -1
- package/out/features/formatter.d.ts +2 -0
- package/out/features/formatter.d.ts.map +1 -1
- package/out/features/formatter.js +36 -7
- package/out/features/formatter.js.map +1 -1
- package/out/features/formattingUtils.d.ts +38 -0
- package/out/features/formattingUtils.d.ts.map +1 -0
- package/out/features/formattingUtils.js +107 -0
- package/out/features/formattingUtils.js.map +1 -0
- package/out/features/hover.d.ts +2 -5
- package/out/features/hover.d.ts.map +1 -1
- package/out/features/hover.js +60 -36
- package/out/features/hover.js.map +1 -1
- package/out/features/inlayHints.d.ts +7 -5
- package/out/features/inlayHints.d.ts.map +1 -1
- package/out/features/inlayHints.js +164 -91
- package/out/features/inlayHints.js.map +1 -1
- package/out/features/semanticTokens.d.ts.map +1 -1
- package/out/features/semanticTokens.js +22 -1
- package/out/features/semanticTokens.js.map +1 -1
- package/out/features/symbols.d.ts.map +1 -1
- package/out/features/symbols.js +9 -3
- package/out/features/symbols.js.map +1 -1
- package/out/features/validator.d.ts +0 -8
- package/out/features/validator.d.ts.map +1 -1
- package/out/features/validator.js +367 -171
- package/out/features/validator.js.map +1 -1
- package/out/parser/ast.d.ts +2 -2
- package/out/parser/ast.d.ts.map +1 -1
- package/out/parser/ast.js +10 -5
- package/out/parser/ast.js.map +1 -1
- package/out/parser/index.d.ts +8 -5
- package/out/parser/index.d.ts.map +1 -1
- package/out/parser/index.js +0 -7
- package/out/parser/index.js.map +1 -1
- package/out/server/configFile.d.ts +104 -0
- package/out/server/configFile.d.ts.map +1 -0
- package/out/server/configFile.js +231 -0
- package/out/server/configFile.js.map +1 -0
- package/out/server/settings.d.ts +9 -0
- package/out/server/settings.d.ts.map +1 -1
- package/out/server/settings.js +9 -0
- package/out/server/settings.js.map +1 -1
- package/out/server/workspace.d.ts +126 -0
- package/out/server/workspace.d.ts.map +1 -0
- package/out/server/workspace.js +605 -0
- package/out/server/workspace.js.map +1 -0
- package/out/server.js +438 -29
- package/out/server.js.map +1 -1
- package/out/types.d.ts +15 -4
- package/out/types.d.ts.map +1 -1
- package/out/types.js +11 -0
- package/out/types.js.map +1 -1
- package/out/utils/amountFormatter.d.ts +26 -0
- package/out/utils/amountFormatter.d.ts.map +1 -0
- package/out/utils/amountFormatter.js +91 -0
- package/out/utils/amountFormatter.js.map +1 -0
- package/out/utils/balanceCalculator.d.ts +32 -0
- package/out/utils/balanceCalculator.d.ts.map +1 -0
- package/out/utils/balanceCalculator.js +93 -0
- package/out/utils/balanceCalculator.js.map +1 -0
- package/out/utils/index.d.ts.map +1 -1
- package/out/utils/index.js +2 -1
- package/out/utils/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@ exports.validator = exports.Validator = void 0;
|
|
|
13
13
|
const node_1 = require("vscode-languageserver/node");
|
|
14
14
|
const uri_1 = require("../utils/uri");
|
|
15
15
|
const settings_1 = require("../server/settings");
|
|
16
|
+
const balanceCalculator_1 = require("../utils/balanceCalculator");
|
|
17
|
+
const amountFormatter_1 = require("../utils/amountFormatter");
|
|
16
18
|
class Validator {
|
|
17
19
|
/**
|
|
18
20
|
* Validate a parsed hledger document
|
|
@@ -30,11 +32,18 @@ class Validator {
|
|
|
30
32
|
// Otherwise use defaults
|
|
31
33
|
return settings_1.defaultSettings.validation?.[key] ?? true;
|
|
32
34
|
};
|
|
35
|
+
// Normalize document URI for consistent comparison
|
|
36
|
+
const normalizedDocUri = (0, uri_1.toFileUri)((0, uri_1.toFilePath)(document.uri));
|
|
33
37
|
// Validate each transaction
|
|
34
38
|
for (const transaction of parsedDoc.transactions) {
|
|
39
|
+
// Only validate transactions in the current document
|
|
40
|
+
// (workspace parsing may include transactions from other files)
|
|
41
|
+
if (transaction.sourceUri !== normalizedDocUri) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
35
44
|
// Check balance
|
|
36
45
|
if (isEnabled('balance')) {
|
|
37
|
-
const balanceIssues = this.validateBalance(transaction, document);
|
|
46
|
+
const balanceIssues = this.validateBalance(transaction, document, parsedDoc);
|
|
38
47
|
diagnostics.push(...balanceIssues);
|
|
39
48
|
}
|
|
40
49
|
// Check missing amounts
|
|
@@ -87,36 +96,15 @@ class Validator {
|
|
|
87
96
|
* Validate transaction balance
|
|
88
97
|
* Transactions must balance (all amounts sum to zero per commodity)
|
|
89
98
|
*/
|
|
90
|
-
validateBalance(transaction, document) {
|
|
99
|
+
validateBalance(transaction, document, parsedDoc) {
|
|
91
100
|
const diagnostics = [];
|
|
92
|
-
//
|
|
93
|
-
const balances =
|
|
101
|
+
// Calculate transaction balance by commodity
|
|
102
|
+
const balances = (0, balanceCalculator_1.calculateTransactionBalance)(transaction);
|
|
94
103
|
// Count how many postings have amounts
|
|
95
104
|
let postingsWithAmounts = 0;
|
|
96
105
|
for (const posting of transaction.postings) {
|
|
97
106
|
if (posting.amount) {
|
|
98
107
|
postingsWithAmounts++;
|
|
99
|
-
// If posting has a cost, use the cost commodity for balance calculation
|
|
100
|
-
if (posting.cost) {
|
|
101
|
-
const costCommodity = posting.cost.amount.commodity || '';
|
|
102
|
-
let costValue;
|
|
103
|
-
if (posting.cost.type === 'unit') {
|
|
104
|
-
// @ unitPrice: total cost = quantity * unitPrice
|
|
105
|
-
costValue = posting.amount.quantity * posting.cost.amount.quantity;
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
// @@ totalPrice: use total price directly
|
|
109
|
-
costValue = posting.cost.amount.quantity;
|
|
110
|
-
}
|
|
111
|
-
const current = balances.get(costCommodity) || 0;
|
|
112
|
-
balances.set(costCommodity, current + costValue);
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
// No cost notation, use the posting's commodity
|
|
116
|
-
const commodity = posting.amount.commodity || '';
|
|
117
|
-
const current = balances.get(commodity) || 0;
|
|
118
|
-
balances.set(commodity, current + posting.amount.quantity);
|
|
119
|
-
}
|
|
120
108
|
}
|
|
121
109
|
}
|
|
122
110
|
// If all postings have amounts, check if they balance
|
|
@@ -124,11 +112,13 @@ class Validator {
|
|
|
124
112
|
for (const [commodity, balance] of balances.entries()) {
|
|
125
113
|
// Allow for small floating point errors
|
|
126
114
|
if (Math.abs(balance) > 0.005) {
|
|
127
|
-
const
|
|
115
|
+
const formattedBalance = commodity
|
|
116
|
+
? (0, amountFormatter_1.formatAmount)(balance, commodity, parsedDoc)
|
|
117
|
+
: balance.toFixed(2);
|
|
128
118
|
diagnostics.push({
|
|
129
119
|
severity: node_1.DiagnosticSeverity.Error,
|
|
130
120
|
range: this.getTransactionRange(transaction, document),
|
|
131
|
-
message: `Transaction does not balance: ${
|
|
121
|
+
message: `Transaction does not balance: ${formattedBalance} off`,
|
|
132
122
|
source: 'hledger'
|
|
133
123
|
});
|
|
134
124
|
}
|
|
@@ -159,6 +149,8 @@ class Validator {
|
|
|
159
149
|
*/
|
|
160
150
|
validateUndeclaredItems(document, parsedDoc, settings, checkAccounts = true, checkPayees = true, checkCommodities = true, checkTags = true) {
|
|
161
151
|
const diagnostics = [];
|
|
152
|
+
// Normalize document URI for consistent comparison
|
|
153
|
+
const normalizedDocUri = (0, uri_1.toFileUri)((0, uri_1.toFilePath)(document.uri));
|
|
162
154
|
// Helper to convert severity string to DiagnosticSeverity
|
|
163
155
|
const getSeverity = (severityStr) => {
|
|
164
156
|
switch (severityStr) {
|
|
@@ -173,77 +165,281 @@ class Validator {
|
|
|
173
165
|
const markAllInstances = settings?.validation?.markAllUndeclaredInstances ?? settings_1.defaultSettings.validation?.markAllUndeclaredInstances ?? true;
|
|
174
166
|
// Check undeclared accounts (if enabled)
|
|
175
167
|
if (checkAccounts) {
|
|
176
|
-
const undeclaredAccounts = Array.from(parsedDoc.accounts.values()).filter(a => !a.declared);
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
168
|
+
const undeclaredAccounts = new Set(Array.from(parsedDoc.accounts.values()).filter(a => !a.declared).map(a => a.name));
|
|
169
|
+
if (undeclaredAccounts.size > 0) {
|
|
170
|
+
const text = document.getText();
|
|
171
|
+
const lines = text.split('\n');
|
|
172
|
+
const processedAccounts = new Set();
|
|
173
|
+
// Iterate through transactions to find account usage locations
|
|
174
|
+
for (const transaction of parsedDoc.transactions) {
|
|
175
|
+
// Only process transactions from the current document
|
|
176
|
+
if (transaction.sourceUri && transaction.sourceUri !== normalizedDocUri) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (transaction.line !== undefined) {
|
|
180
|
+
let postingLineOffset = 1;
|
|
181
|
+
for (const posting of transaction.postings) {
|
|
182
|
+
const accountName = posting.account;
|
|
183
|
+
if (undeclaredAccounts.has(accountName)) {
|
|
184
|
+
if (!markAllInstances && processedAccounts.has(accountName)) {
|
|
185
|
+
postingLineOffset++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// Find the posting line
|
|
189
|
+
const postingLine = transaction.line + postingLineOffset;
|
|
190
|
+
if (postingLine < lines.length) {
|
|
191
|
+
const line = lines[postingLine];
|
|
192
|
+
// Account name is after indentation, before amount/comment
|
|
193
|
+
const accountIndex = line.indexOf(accountName);
|
|
194
|
+
if (accountIndex !== -1) {
|
|
195
|
+
diagnostics.push({
|
|
196
|
+
severity: getSeverity(settings?.severity?.undeclaredAccounts),
|
|
197
|
+
range: {
|
|
198
|
+
start: { line: postingLine, character: accountIndex },
|
|
199
|
+
end: { line: postingLine, character: accountIndex + accountName.length }
|
|
200
|
+
},
|
|
201
|
+
message: `Account "${accountName}" is used but not declared with 'account' directive`,
|
|
202
|
+
source: 'hledger',
|
|
203
|
+
code: 'undeclared-account',
|
|
204
|
+
data: { accountName }
|
|
205
|
+
});
|
|
206
|
+
processedAccounts.add(accountName);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
postingLineOffset++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
190
213
|
}
|
|
191
214
|
}
|
|
192
215
|
}
|
|
193
216
|
// Check undeclared payees (if enabled)
|
|
194
217
|
if (checkPayees) {
|
|
195
|
-
const undeclaredPayees = Array.from(parsedDoc.payees.values()).filter(p => !p.declared);
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
218
|
+
const undeclaredPayees = new Set(Array.from(parsedDoc.payees.values()).filter(p => !p.declared).map(p => p.name));
|
|
219
|
+
if (undeclaredPayees.size > 0) {
|
|
220
|
+
const text = document.getText();
|
|
221
|
+
const lines = text.split('\n');
|
|
222
|
+
const processedPayees = new Set();
|
|
223
|
+
// Iterate through transactions to find payee usage locations
|
|
224
|
+
for (const transaction of parsedDoc.transactions) {
|
|
225
|
+
// Only process transactions from the current document
|
|
226
|
+
if (transaction.sourceUri && transaction.sourceUri !== normalizedDocUri) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const payeeName = transaction.payee;
|
|
230
|
+
if (undeclaredPayees.has(payeeName) && transaction.line !== undefined) {
|
|
231
|
+
if (!markAllInstances && processedPayees.has(payeeName)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const line = lines[transaction.line];
|
|
235
|
+
// Payee is in the transaction header after date, status, and code
|
|
236
|
+
const payeeIndex = line.indexOf(payeeName);
|
|
237
|
+
if (payeeIndex !== -1) {
|
|
238
|
+
diagnostics.push({
|
|
239
|
+
severity: getSeverity(settings?.severity?.undeclaredPayees),
|
|
240
|
+
range: {
|
|
241
|
+
start: { line: transaction.line, character: payeeIndex },
|
|
242
|
+
end: { line: transaction.line, character: payeeIndex + payeeName.length }
|
|
243
|
+
},
|
|
244
|
+
message: `Payee "${payeeName}" is used but not declared with 'payee' directive`,
|
|
245
|
+
source: 'hledger',
|
|
246
|
+
code: 'undeclared-payee',
|
|
247
|
+
data: { payeeName }
|
|
248
|
+
});
|
|
249
|
+
processedPayees.add(payeeName);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
209
252
|
}
|
|
210
253
|
}
|
|
211
254
|
}
|
|
212
255
|
// Check undeclared commodities (if enabled)
|
|
213
256
|
if (checkCommodities) {
|
|
214
|
-
const undeclaredCommodities = Array.from(parsedDoc.commodities.values()).filter(c => !c.declared);
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
257
|
+
const undeclaredCommodities = new Set(Array.from(parsedDoc.commodities.values()).filter(c => !c.declared).map(c => c.name));
|
|
258
|
+
if (undeclaredCommodities.size > 0) {
|
|
259
|
+
const text = document.getText();
|
|
260
|
+
const lines = text.split('\n');
|
|
261
|
+
const processedCommodities = new Set();
|
|
262
|
+
// Iterate through transactions to find commodity usage locations
|
|
263
|
+
for (const transaction of parsedDoc.transactions) {
|
|
264
|
+
// Only process transactions from the current document
|
|
265
|
+
if (transaction.sourceUri && transaction.sourceUri !== normalizedDocUri) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (transaction.line !== undefined) {
|
|
269
|
+
let postingLineOffset = 1;
|
|
270
|
+
for (const posting of transaction.postings) {
|
|
271
|
+
const postingLine = transaction.line + postingLineOffset;
|
|
272
|
+
if (postingLine < lines.length) {
|
|
273
|
+
const line = lines[postingLine];
|
|
274
|
+
// Check commodity in posting amount
|
|
275
|
+
if (posting.amount?.commodity && undeclaredCommodities.has(posting.amount.commodity)) {
|
|
276
|
+
const commodityName = posting.amount.commodity;
|
|
277
|
+
if (!markAllInstances && processedCommodities.has(commodityName)) {
|
|
278
|
+
postingLineOffset++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const commodityIndex = line.indexOf(commodityName);
|
|
282
|
+
if (commodityIndex !== -1) {
|
|
283
|
+
diagnostics.push({
|
|
284
|
+
severity: getSeverity(settings?.severity?.undeclaredCommodities),
|
|
285
|
+
range: {
|
|
286
|
+
start: { line: postingLine, character: commodityIndex },
|
|
287
|
+
end: { line: postingLine, character: commodityIndex + commodityName.length }
|
|
288
|
+
},
|
|
289
|
+
message: `Commodity "${commodityName}" is used but not declared with 'commodity' directive`,
|
|
290
|
+
source: 'hledger',
|
|
291
|
+
code: 'undeclared-commodity',
|
|
292
|
+
data: { commodityName }
|
|
293
|
+
});
|
|
294
|
+
processedCommodities.add(commodityName);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Check commodity in cost notation
|
|
298
|
+
if (posting.cost?.amount?.commodity && undeclaredCommodities.has(posting.cost.amount.commodity)) {
|
|
299
|
+
const commodityName = posting.cost.amount.commodity;
|
|
300
|
+
if (!markAllInstances && processedCommodities.has(commodityName)) {
|
|
301
|
+
postingLineOffset++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Cost appears after @ or @@
|
|
305
|
+
const atIndex = line.indexOf('@');
|
|
306
|
+
if (atIndex !== -1) {
|
|
307
|
+
const afterAt = line.substring(atIndex);
|
|
308
|
+
const commodityIndex = afterAt.indexOf(commodityName);
|
|
309
|
+
if (commodityIndex !== -1) {
|
|
310
|
+
const absoluteIndex = atIndex + commodityIndex;
|
|
311
|
+
diagnostics.push({
|
|
312
|
+
severity: getSeverity(settings?.severity?.undeclaredCommodities),
|
|
313
|
+
range: {
|
|
314
|
+
start: { line: postingLine, character: absoluteIndex },
|
|
315
|
+
end: { line: postingLine, character: absoluteIndex + commodityName.length }
|
|
316
|
+
},
|
|
317
|
+
message: `Commodity "${commodityName}" is used but not declared with 'commodity' directive`,
|
|
318
|
+
source: 'hledger',
|
|
319
|
+
code: 'undeclared-commodity',
|
|
320
|
+
data: { commodityName }
|
|
321
|
+
});
|
|
322
|
+
processedCommodities.add(commodityName);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Check commodity in balance assertion
|
|
327
|
+
if (posting.assertion?.commodity && undeclaredCommodities.has(posting.assertion.commodity)) {
|
|
328
|
+
const commodityName = posting.assertion.commodity;
|
|
329
|
+
if (!markAllInstances && processedCommodities.has(commodityName)) {
|
|
330
|
+
postingLineOffset++;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Assertion appears after =
|
|
334
|
+
const equalsIndex = line.indexOf('=');
|
|
335
|
+
if (equalsIndex !== -1) {
|
|
336
|
+
const afterEquals = line.substring(equalsIndex);
|
|
337
|
+
const commodityIndex = afterEquals.indexOf(commodityName);
|
|
338
|
+
if (commodityIndex !== -1) {
|
|
339
|
+
const absoluteIndex = equalsIndex + commodityIndex;
|
|
340
|
+
diagnostics.push({
|
|
341
|
+
severity: getSeverity(settings?.severity?.undeclaredCommodities),
|
|
342
|
+
range: {
|
|
343
|
+
start: { line: postingLine, character: absoluteIndex },
|
|
344
|
+
end: { line: postingLine, character: absoluteIndex + commodityName.length }
|
|
345
|
+
},
|
|
346
|
+
message: `Commodity "${commodityName}" is used but not declared with 'commodity' directive`,
|
|
347
|
+
source: 'hledger',
|
|
348
|
+
code: 'undeclared-commodity',
|
|
349
|
+
data: { commodityName }
|
|
350
|
+
});
|
|
351
|
+
processedCommodities.add(commodityName);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
postingLineOffset++;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
228
359
|
}
|
|
229
360
|
}
|
|
230
361
|
}
|
|
231
362
|
// Check undeclared tags (if enabled)
|
|
232
363
|
if (checkTags) {
|
|
233
|
-
const undeclaredTags = Array.from(parsedDoc.tags.values()).filter(t => !t.declared);
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
364
|
+
const undeclaredTags = new Set(Array.from(parsedDoc.tags.values()).filter(t => !t.declared).map(t => t.name));
|
|
365
|
+
if (undeclaredTags.size > 0) {
|
|
366
|
+
const text = document.getText();
|
|
367
|
+
const lines = text.split('\n');
|
|
368
|
+
const processedTags = new Set();
|
|
369
|
+
// Iterate through transactions to find tag usage locations
|
|
370
|
+
for (const transaction of parsedDoc.transactions) {
|
|
371
|
+
// Only process transactions from the current document
|
|
372
|
+
if (transaction.sourceUri && transaction.sourceUri !== normalizedDocUri) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
// Check transaction-level tags
|
|
376
|
+
if (transaction.tags) {
|
|
377
|
+
for (const tagName of Object.keys(transaction.tags)) {
|
|
378
|
+
if (undeclaredTags.has(tagName) && transaction.line !== undefined) {
|
|
379
|
+
if (!markAllInstances && processedTags.has(tagName)) {
|
|
380
|
+
continue; // Skip if we already reported this tag
|
|
381
|
+
}
|
|
382
|
+
const line = lines[transaction.line];
|
|
383
|
+
const commentIndex = line.indexOf(';');
|
|
384
|
+
if (commentIndex !== -1) {
|
|
385
|
+
const tagIndex = line.indexOf(tagName + ':', commentIndex);
|
|
386
|
+
if (tagIndex !== -1) {
|
|
387
|
+
diagnostics.push({
|
|
388
|
+
severity: getSeverity(settings?.severity?.undeclaredTags || 'information'),
|
|
389
|
+
range: {
|
|
390
|
+
start: { line: transaction.line, character: tagIndex },
|
|
391
|
+
end: { line: transaction.line, character: tagIndex + tagName.length + 1 }
|
|
392
|
+
},
|
|
393
|
+
message: `Tag "${tagName}" is used but not declared with 'tag' directive`,
|
|
394
|
+
source: 'hledger',
|
|
395
|
+
code: 'undeclared-tag',
|
|
396
|
+
data: { tagName }
|
|
397
|
+
});
|
|
398
|
+
processedTags.add(tagName);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Check posting-level tags
|
|
405
|
+
if (transaction.line !== undefined) {
|
|
406
|
+
let postingLineOffset = 1;
|
|
407
|
+
for (const posting of transaction.postings) {
|
|
408
|
+
if (posting.tags) {
|
|
409
|
+
for (const tagName of Object.keys(posting.tags)) {
|
|
410
|
+
if (undeclaredTags.has(tagName)) {
|
|
411
|
+
if (!markAllInstances && processedTags.has(tagName)) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
// Find the posting line
|
|
415
|
+
const postingLine = transaction.line + postingLineOffset;
|
|
416
|
+
if (postingLine < lines.length) {
|
|
417
|
+
const line = lines[postingLine];
|
|
418
|
+
const commentIndex = line.indexOf(';');
|
|
419
|
+
if (commentIndex !== -1) {
|
|
420
|
+
const tagIndex = line.indexOf(tagName + ':', commentIndex);
|
|
421
|
+
if (tagIndex !== -1) {
|
|
422
|
+
diagnostics.push({
|
|
423
|
+
severity: getSeverity(settings?.severity?.undeclaredTags || 'information'),
|
|
424
|
+
range: {
|
|
425
|
+
start: { line: postingLine, character: tagIndex },
|
|
426
|
+
end: { line: postingLine, character: tagIndex + tagName.length + 1 }
|
|
427
|
+
},
|
|
428
|
+
message: `Tag "${tagName}" is used but not declared with 'tag' directive`,
|
|
429
|
+
source: 'hledger',
|
|
430
|
+
code: 'undeclared-tag',
|
|
431
|
+
data: { tagName }
|
|
432
|
+
});
|
|
433
|
+
processedTags.add(tagName);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
postingLineOffset++;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
247
443
|
}
|
|
248
444
|
}
|
|
249
445
|
}
|
|
@@ -289,74 +485,30 @@ class Validator {
|
|
|
289
485
|
}
|
|
290
486
|
return null;
|
|
291
487
|
}
|
|
292
|
-
/**
|
|
293
|
-
* Find all occurrences of a string in the document
|
|
294
|
-
*/
|
|
295
|
-
findAllOccurrences(document, searchStr) {
|
|
296
|
-
const text = document.getText();
|
|
297
|
-
const lines = text.split('\n');
|
|
298
|
-
const ranges = [];
|
|
299
|
-
for (let i = 0; i < lines.length; i++) {
|
|
300
|
-
const line = lines[i];
|
|
301
|
-
let startIndex = 0;
|
|
302
|
-
let index;
|
|
303
|
-
// Find all occurrences in this line
|
|
304
|
-
while ((index = line.indexOf(searchStr, startIndex)) !== -1) {
|
|
305
|
-
ranges.push({
|
|
306
|
-
start: { line: i, character: index },
|
|
307
|
-
end: { line: i, character: index + searchStr.length }
|
|
308
|
-
});
|
|
309
|
-
startIndex = index + 1; // Move past this occurrence to find next
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return ranges;
|
|
313
|
-
}
|
|
314
488
|
/**
|
|
315
489
|
* Validate date ordering
|
|
316
490
|
* Warn if transactions are not in chronological order
|
|
317
491
|
*/
|
|
318
492
|
validateDateOrdering(transactions, document) {
|
|
319
493
|
const diagnostics = [];
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
494
|
+
// Only validate transactions in the current document
|
|
495
|
+
const normalizedDocUri = (0, uri_1.toFileUri)((0, uri_1.toFilePath)(document.uri));
|
|
496
|
+
const documentTransactions = transactions.filter(t => t.sourceUri === normalizedDocUri);
|
|
497
|
+
for (let i = 1; i < documentTransactions.length; i++) {
|
|
498
|
+
const prevDate = this.parseDate(documentTransactions[i - 1].date);
|
|
499
|
+
const currDate = this.parseDate(documentTransactions[i].date);
|
|
323
500
|
if (prevDate && currDate && currDate < prevDate) {
|
|
324
|
-
const range = this.getTransactionRange(
|
|
501
|
+
const range = this.getTransactionRange(documentTransactions[i], document);
|
|
325
502
|
diagnostics.push({
|
|
326
503
|
severity: node_1.DiagnosticSeverity.Warning,
|
|
327
504
|
range,
|
|
328
|
-
message: `Transaction date ${
|
|
505
|
+
message: `Transaction date ${documentTransactions[i].date} is before previous transaction date ${documentTransactions[i - 1].date}`,
|
|
329
506
|
source: 'hledger'
|
|
330
507
|
});
|
|
331
508
|
}
|
|
332
509
|
}
|
|
333
510
|
return diagnostics;
|
|
334
511
|
}
|
|
335
|
-
/**
|
|
336
|
-
* Format an amount with commodity according to declared format
|
|
337
|
-
*/
|
|
338
|
-
formatAmountWithCommodity(quantity, commodityName, parsedDoc) {
|
|
339
|
-
// Find commodity format
|
|
340
|
-
const commodity = parsedDoc.commodities.get(commodityName);
|
|
341
|
-
if (!commodity?.format) {
|
|
342
|
-
// No format declared, use default: amount then commodity with space
|
|
343
|
-
return commodityName ? `${quantity.toFixed(2)} ${commodityName}` : quantity.toFixed(2);
|
|
344
|
-
}
|
|
345
|
-
const format = commodity.format;
|
|
346
|
-
const symbol = format.symbol || commodityName;
|
|
347
|
-
const symbolOnLeft = format.symbolOnLeft ?? false;
|
|
348
|
-
const spaceBetween = format.spaceBetween ?? true;
|
|
349
|
-
const space = spaceBetween ? ' ' : '';
|
|
350
|
-
// Use precision from format, or default to 2
|
|
351
|
-
const precision = format.precision ?? 2;
|
|
352
|
-
const formattedNumber = quantity.toFixed(precision);
|
|
353
|
-
if (symbolOnLeft) {
|
|
354
|
-
return `${symbol}${space}${formattedNumber}`;
|
|
355
|
-
}
|
|
356
|
-
else {
|
|
357
|
-
return `${formattedNumber}${space}${symbol}`;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
512
|
/**
|
|
361
513
|
* Validate balance assertions
|
|
362
514
|
* Check if balance assertions match calculated balances
|
|
@@ -365,6 +517,8 @@ class Validator {
|
|
|
365
517
|
const diagnostics = [];
|
|
366
518
|
// Track running balances per account per commodity
|
|
367
519
|
const balances = new Map();
|
|
520
|
+
// Process ALL transactions to calculate running balances correctly
|
|
521
|
+
// (workspace parsing includes transactions from all files)
|
|
368
522
|
for (const transaction of transactions) {
|
|
369
523
|
for (const posting of transaction.postings) {
|
|
370
524
|
// Update running balance
|
|
@@ -379,8 +533,9 @@ class Validator {
|
|
|
379
533
|
accountBalances.set(commodity, currentBalance + posting.amount.quantity);
|
|
380
534
|
balances.set(posting.account, accountBalances);
|
|
381
535
|
}
|
|
382
|
-
// Check assertion
|
|
383
|
-
|
|
536
|
+
// Check assertion - but only create diagnostics for assertions in the current document
|
|
537
|
+
const normalizedDocUri = (0, uri_1.toFileUri)((0, uri_1.toFilePath)(document.uri));
|
|
538
|
+
if (posting.assertion && transaction.sourceUri === normalizedDocUri) {
|
|
384
539
|
const accountBalances = balances.get(posting.account);
|
|
385
540
|
const commodity = posting.assertion.commodity || '';
|
|
386
541
|
const expectedBalance = posting.assertion.quantity;
|
|
@@ -388,8 +543,8 @@ class Validator {
|
|
|
388
543
|
// Allow for small floating point errors
|
|
389
544
|
if (Math.abs(actualBalance - expectedBalance) > 0.005) {
|
|
390
545
|
const range = this.findPostingRange(transaction, posting, document);
|
|
391
|
-
const expectedFormatted =
|
|
392
|
-
const actualFormatted =
|
|
546
|
+
const expectedFormatted = (0, amountFormatter_1.formatAmount)(expectedBalance, commodity, parsedDoc);
|
|
547
|
+
const actualFormatted = (0, amountFormatter_1.formatAmount)(actualBalance, commodity, parsedDoc);
|
|
393
548
|
diagnostics.push({
|
|
394
549
|
severity: node_1.DiagnosticSeverity.Error,
|
|
395
550
|
range,
|
|
@@ -580,50 +735,91 @@ class Validator {
|
|
|
580
735
|
const includeDirectives = parsedDoc.directives.filter(d => d.type === 'include');
|
|
581
736
|
for (const directive of includeDirectives) {
|
|
582
737
|
const includePath = directive.value;
|
|
583
|
-
//
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
if (
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
738
|
+
// Check if this is a glob pattern
|
|
739
|
+
const isGlob = /[*?\[\]{}]/.test(includePath);
|
|
740
|
+
if (isGlob) {
|
|
741
|
+
// For glob patterns, expand to all matching files
|
|
742
|
+
const resolvedPaths = (0, uri_1.resolveIncludePaths)(includePath, baseUri);
|
|
743
|
+
// Check if glob matched any files (if enabled)
|
|
744
|
+
if (resolvedPaths.length === 0 && checkMissingFiles) {
|
|
745
|
+
const range = this.findFirstOccurrence(document, includePath);
|
|
746
|
+
if (range) {
|
|
747
|
+
diagnostics.push({
|
|
748
|
+
severity: node_1.DiagnosticSeverity.Error,
|
|
749
|
+
range,
|
|
750
|
+
message: `Include glob pattern matches no files: ${includePath}`,
|
|
751
|
+
source: 'hledger'
|
|
752
|
+
});
|
|
753
|
+
}
|
|
595
754
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
755
|
+
// Check for circular includes for each matched file
|
|
756
|
+
if (checkCircularIncludes) {
|
|
757
|
+
for (const resolvedPath of resolvedPaths) {
|
|
758
|
+
const includeDoc = fileReader(resolvedPath);
|
|
759
|
+
if (includeDoc) {
|
|
760
|
+
const circularCheck = this.checkCircularInclude(document.uri, includeDoc, fileReader, new Set([baseUri, resolvedPath]));
|
|
761
|
+
if (circularCheck) {
|
|
762
|
+
const range = this.findFirstOccurrence(document, includePath);
|
|
763
|
+
if (range) {
|
|
764
|
+
diagnostics.push({
|
|
765
|
+
severity: node_1.DiagnosticSeverity.Error,
|
|
766
|
+
range,
|
|
767
|
+
message: `Circular include detected in glob: ${includePath} (via ${resolvedPath})`,
|
|
768
|
+
source: 'hledger'
|
|
769
|
+
});
|
|
770
|
+
break; // Only report once per glob pattern
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
611
775
|
}
|
|
612
776
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
777
|
+
else {
|
|
778
|
+
// Single file include (existing logic)
|
|
779
|
+
const resolvedPath = (0, uri_1.resolveIncludePath)(includePath, baseUri);
|
|
780
|
+
// Check for duplicate includes in the same document
|
|
781
|
+
if (visited.has(resolvedPath)) {
|
|
782
|
+
const range = this.findFirstOccurrence(document, includePath);
|
|
783
|
+
if (range) {
|
|
784
|
+
diagnostics.push({
|
|
785
|
+
severity: node_1.DiagnosticSeverity.Warning,
|
|
786
|
+
range,
|
|
787
|
+
message: `Duplicate include: ${includePath}`,
|
|
788
|
+
source: 'hledger'
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
visited.add(resolvedPath);
|
|
794
|
+
// Check if file exists (if enabled)
|
|
795
|
+
const includeDoc = fileReader(resolvedPath);
|
|
796
|
+
if (!includeDoc && checkMissingFiles) {
|
|
797
|
+
// File doesn't exist
|
|
617
798
|
const range = this.findFirstOccurrence(document, includePath);
|
|
618
799
|
if (range) {
|
|
619
800
|
diagnostics.push({
|
|
620
801
|
severity: node_1.DiagnosticSeverity.Error,
|
|
621
802
|
range,
|
|
622
|
-
message: `
|
|
803
|
+
message: `Include file not found: ${includePath}`,
|
|
623
804
|
source: 'hledger'
|
|
624
805
|
});
|
|
625
806
|
}
|
|
626
807
|
}
|
|
808
|
+
// Check for circular includes (if enabled and file exists)
|
|
809
|
+
if (includeDoc && checkCircularIncludes) {
|
|
810
|
+
const circularCheck = this.checkCircularInclude(document.uri, includeDoc, fileReader, new Set([baseUri, resolvedPath]));
|
|
811
|
+
if (circularCheck) {
|
|
812
|
+
const range = this.findFirstOccurrence(document, includePath);
|
|
813
|
+
if (range) {
|
|
814
|
+
diagnostics.push({
|
|
815
|
+
severity: node_1.DiagnosticSeverity.Error,
|
|
816
|
+
range,
|
|
817
|
+
message: `Circular include detected: ${includePath}`,
|
|
818
|
+
source: 'hledger'
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
627
823
|
}
|
|
628
824
|
}
|
|
629
825
|
return diagnostics;
|