@atomic-ehr/fhirpath 0.0.2 → 0.0.3

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 (143) hide show
  1. package/README.md +716 -238
  2. package/dist/index.d.ts +225 -119
  3. package/dist/index.js +10911 -5600
  4. package/dist/index.js.map +1 -1
  5. package/package.json +9 -4
  6. package/src/analyzer/augmentor.ts +242 -0
  7. package/src/analyzer/cursor-services.ts +75 -0
  8. package/src/analyzer/scope-manager.ts +57 -0
  9. package/src/analyzer/trivia-indexer.ts +58 -0
  10. package/src/analyzer/type-compat.ts +157 -0
  11. package/src/analyzer/utils.ts +132 -0
  12. package/src/analyzer.ts +921 -1208
  13. package/src/completion-provider.ts +209 -191
  14. package/src/{quantity-value.ts → complex-types/quantity-value.ts} +112 -22
  15. package/src/complex-types/temporal.ts +1737 -0
  16. package/src/errors.ts +25 -3
  17. package/src/index.ts +17 -104
  18. package/src/inspect.ts +4 -4
  19. package/src/{boxing.ts → interpreter/boxing.ts} +1 -1
  20. package/src/interpreter/navigator.ts +94 -0
  21. package/src/interpreter/runtime-context.ts +273 -0
  22. package/src/interpreter.ts +435 -469
  23. package/src/lexer.ts +188 -210
  24. package/src/model-provider.ts +71 -43
  25. package/src/operations/abs-function.ts +1 -1
  26. package/src/operations/aggregate-function.ts +84 -5
  27. package/src/operations/all-function.ts +4 -3
  28. package/src/operations/allFalse-function.ts +2 -1
  29. package/src/operations/allTrue-function.ts +2 -1
  30. package/src/operations/and-operator.ts +2 -1
  31. package/src/operations/anyFalse-function.ts +2 -1
  32. package/src/operations/anyTrue-function.ts +2 -1
  33. package/src/operations/as-function.ts +58 -0
  34. package/src/operations/as-operator.ts +57 -19
  35. package/src/operations/ceiling-function.ts +1 -1
  36. package/src/operations/children-function.ts +14 -5
  37. package/src/operations/combine-function.ts +6 -3
  38. package/src/operations/combine-operator.ts +6 -7
  39. package/src/operations/comparison.ts +692 -0
  40. package/src/operations/contains-function.ts +1 -1
  41. package/src/operations/contains-operator.ts +2 -1
  42. package/src/operations/convertsToBoolean-function.ts +78 -0
  43. package/src/operations/convertsToDecimal-function.ts +82 -0
  44. package/src/operations/convertsToInteger-function.ts +71 -0
  45. package/src/operations/convertsToLong-function.ts +89 -0
  46. package/src/operations/convertsToQuantity-function.ts +116 -0
  47. package/src/operations/convertsToString-function.ts +88 -0
  48. package/src/operations/count-function.ts +2 -1
  49. package/src/operations/dateOf-function.ts +69 -0
  50. package/src/operations/dayOf-function.ts +66 -0
  51. package/src/operations/decimal-boundaries.ts +133 -0
  52. package/src/operations/defineVariable-function.ts +130 -17
  53. package/src/operations/distinct-function.ts +1 -1
  54. package/src/operations/div-operator.ts +1 -1
  55. package/src/operations/divide-operator.ts +12 -7
  56. package/src/operations/dot-operator.ts +1 -1
  57. package/src/operations/empty-function.ts +30 -21
  58. package/src/operations/endsWith-function.ts +6 -1
  59. package/src/operations/equal-operator.ts +23 -32
  60. package/src/operations/equivalent-operator.ts +13 -53
  61. package/src/operations/exclude-function.ts +2 -1
  62. package/src/operations/exists-function.ts +4 -3
  63. package/src/operations/first-function.ts +1 -1
  64. package/src/operations/floor-function.ts +1 -1
  65. package/src/operations/greater-operator.ts +20 -3
  66. package/src/operations/greater-or-equal-operator.ts +20 -3
  67. package/src/operations/highBoundary-function.ts +120 -0
  68. package/src/operations/hourOf-function.ts +66 -0
  69. package/src/operations/iif-function.ts +186 -7
  70. package/src/operations/implies-operator.ts +1 -1
  71. package/src/operations/in-operator.ts +2 -1
  72. package/src/operations/index.ts +41 -0
  73. package/src/operations/indexOf-function.ts +1 -1
  74. package/src/operations/intersect-function.ts +1 -1
  75. package/src/operations/is-function.ts +59 -0
  76. package/src/operations/is-operator.ts +20 -9
  77. package/src/operations/isDistinct-function.ts +2 -1
  78. package/src/operations/join-function.ts +1 -1
  79. package/src/operations/last-function.ts +1 -1
  80. package/src/operations/lastIndexOf-function.ts +85 -0
  81. package/src/operations/length-function.ts +1 -1
  82. package/src/operations/less-operator.ts +20 -3
  83. package/src/operations/less-or-equal-operator.ts +20 -3
  84. package/src/operations/less-than.ts +2 -2
  85. package/src/operations/lowBoundary-function.ts +120 -0
  86. package/src/operations/lower-function.ts +1 -1
  87. package/src/operations/matches-function.ts +86 -0
  88. package/src/operations/matchesFull-function.ts +96 -0
  89. package/src/operations/millisecondOf-function.ts +66 -0
  90. package/src/operations/minus-operator.ts +69 -4
  91. package/src/operations/minuteOf-function.ts +66 -0
  92. package/src/operations/mod-operator.ts +1 -1
  93. package/src/operations/monthOf-function.ts +66 -0
  94. package/src/operations/multiply-operator.ts +27 -3
  95. package/src/operations/not-equal-operator.ts +24 -30
  96. package/src/operations/not-equivalent-operator.ts +13 -53
  97. package/src/operations/not-function.ts +1 -1
  98. package/src/operations/ofType-function.ts +8 -12
  99. package/src/operations/or-operator.ts +2 -1
  100. package/src/operations/plus-operator.ts +71 -7
  101. package/src/operations/power-function.ts +35 -10
  102. package/src/operations/repeat-function.ts +169 -0
  103. package/src/operations/replace-function.ts +1 -1
  104. package/src/operations/replaceMatches-function.ts +120 -0
  105. package/src/operations/round-function.ts +1 -1
  106. package/src/operations/secondOf-function.ts +66 -0
  107. package/src/operations/select-function.ts +66 -5
  108. package/src/operations/single-function.ts +1 -1
  109. package/src/operations/skip-function.ts +1 -1
  110. package/src/operations/split-function.ts +1 -1
  111. package/src/operations/sqrt-function.ts +15 -8
  112. package/src/operations/startsWith-function.ts +1 -1
  113. package/src/operations/subsetOf-function.ts +6 -2
  114. package/src/operations/substring-function.ts +1 -1
  115. package/src/operations/supersetOf-function.ts +6 -2
  116. package/src/operations/tail-function.ts +1 -1
  117. package/src/operations/take-function.ts +1 -1
  118. package/src/operations/temporal-functions.ts +555 -0
  119. package/src/operations/timeOf-function.ts +67 -0
  120. package/src/operations/timezoneOffsetOf-function.ts +69 -0
  121. package/src/operations/toBoolean-function.ts +27 -8
  122. package/src/operations/toChars-function.ts +56 -0
  123. package/src/operations/toDecimal-function.ts +27 -8
  124. package/src/operations/toInteger-function.ts +15 -3
  125. package/src/operations/toLong-function.ts +98 -0
  126. package/src/operations/toQuantity-function.ts +181 -0
  127. package/src/operations/toString-function.ts +45 -3
  128. package/src/operations/trace-function.ts +1 -1
  129. package/src/operations/trim-function.ts +1 -1
  130. package/src/operations/truncate-function.ts +1 -1
  131. package/src/operations/unary-minus-operator.ts +2 -2
  132. package/src/operations/unary-plus-operator.ts +1 -1
  133. package/src/operations/union-function.ts +1 -1
  134. package/src/operations/union-operator.ts +16 -26
  135. package/src/operations/upper-function.ts +1 -1
  136. package/src/operations/where-function.ts +3 -3
  137. package/src/operations/xor-operator.ts +1 -1
  138. package/src/operations/yearOf-function.ts +66 -0
  139. package/src/{cursor-nodes.ts → parser/cursor-nodes.ts} +10 -7
  140. package/src/parser.ts +248 -501
  141. package/src/registry.ts +53 -42
  142. package/src/types.ts +128 -16
  143. package/src/utils/pprint.ts +151 -0
package/src/lexer.ts CHANGED
@@ -23,6 +23,7 @@ export enum TokenType {
23
23
  DATETIME = 4,
24
24
  TIME = 5,
25
25
  QUANTITY = 6, // Quantity literals like 5 'mg'
26
+ DATE = 7, // Date literals like @2020-01-01
26
27
 
27
28
  // Operators (all symbol operators consolidated)
28
29
  OPERATOR = 10, // +, -, *, /, <, >, <=, >=, =, !=, ~, !~, |, &
@@ -76,8 +77,6 @@ export class Lexer {
76
77
  private position: number = 0;
77
78
  private line: number = 1; // Legacy: 1-based for backward compatibility
78
79
  private column: number = 1; // Legacy: 1-based for backward compatibility
79
- private lspLine: number = 0; // LSP: zero-based
80
- private lspCharacter: number = 0; // LSP: zero-based character within line
81
80
  private options: LexerOptions;
82
81
  private lineOffsets: number[] = [0]; // Start positions of each line
83
82
 
@@ -160,25 +159,13 @@ export class Lexer {
160
159
  const start = this.position;
161
160
  const startLine = this.line;
162
161
  const startColumn = this.column;
163
- const char = this.input[this.position];
162
+ const char = this.input[this.position]!;
164
163
  const charCode = this.input.charCodeAt(this.position);
165
164
 
166
165
  // Single character tokens
167
166
  switch (char) {
168
- case '+':
169
- this.advance();
170
- return this.createToken(TokenType.OPERATOR, '+', start, this.position, startLine, startColumn);
171
-
172
- case '-':
173
- this.advance();
174
- return this.createToken(TokenType.OPERATOR, '-', start, this.position, startLine, startColumn);
175
-
176
- case '*':
177
- this.advance();
178
- return this.createToken(TokenType.OPERATOR, '*', start, this.position, startLine, startColumn);
179
-
180
167
  case '/':
181
- // Check for comments
168
+ // Handle comments first; otherwise treat as operator
182
169
  if (this.peek() === '/') {
183
170
  if (this.options.preserveTrivia) {
184
171
  const commentStart = this.position;
@@ -217,53 +204,9 @@ export class Lexer {
217
204
  return null;
218
205
  }
219
206
  }
220
- this.advance();
221
- return this.createToken(TokenType.OPERATOR, '/', start, this.position, startLine, startColumn);
222
-
223
- case '<':
224
- this.advance();
225
- if (this.current() === '=') {
226
- this.advance();
227
- return this.createToken(TokenType.OPERATOR, '<=', start, this.position, startLine, startColumn);
228
- }
229
- return this.createToken(TokenType.OPERATOR, '<', start, this.position, startLine, startColumn);
230
-
231
- case '>':
232
- this.advance();
233
- if (this.current() === '=') {
234
- this.advance();
235
- return this.createToken(TokenType.OPERATOR, '>=', start, this.position, startLine, startColumn);
236
- }
237
- return this.createToken(TokenType.OPERATOR, '>', start, this.position, startLine, startColumn);
238
-
239
- case '=':
240
- this.advance();
241
- return this.createToken(TokenType.OPERATOR, '=', start, this.position, startLine, startColumn);
242
-
243
- case '!':
244
- this.advance();
245
- if (this.current() === '=') {
246
- this.advance();
247
- return this.createToken(TokenType.OPERATOR, '!=', start, this.position, startLine, startColumn);
248
- } else if (this.current() === '~') {
249
- this.advance();
250
- return this.createToken(TokenType.OPERATOR, '!~', start, this.position, startLine, startColumn);
251
- }
252
- // '!' alone is not a valid token in FHIRPath
253
- throw this.error(`Unexpected character '!' at position ${start}`);
254
-
255
- case '~':
256
- this.advance();
257
- return this.createToken(TokenType.OPERATOR, '~', start, this.position, startLine, startColumn);
258
-
259
- case '|':
260
- this.advance();
261
- return this.createToken(TokenType.OPERATOR, '|', start, this.position, startLine, startColumn);
262
-
263
- case '&':
264
- this.advance();
265
- return this.createToken(TokenType.OPERATOR, '&', start, this.position, startLine, startColumn);
266
-
207
+ // Fall through to operator matching after comment checks
208
+ return this.readOperator(start, startLine, startColumn);
209
+
267
210
  case '.':
268
211
  this.advance();
269
212
  return this.createToken(TokenType.DOT, '.', start, this.position, startLine, startColumn);
@@ -315,7 +258,12 @@ export class Lexer {
315
258
  case '$':
316
259
  return this.readSpecialIdentifier();
317
260
  }
318
-
261
+
262
+ // Greedy operator matching for other operator starters
263
+ if (this.isOperatorStarter(char)) {
264
+ return this.readOperator(start, startLine, startColumn);
265
+ }
266
+
319
267
  // Numbers
320
268
  if (charCode >= 48 && charCode <= 57) { // 0-9
321
269
  return this.readNumber();
@@ -340,17 +288,7 @@ export class Lexer {
340
288
  this.advance();
341
289
 
342
290
  // Continue with alphanumeric or underscore
343
- while (this.position < this.input.length) {
344
- const charCode = this.input.charCodeAt(this.position);
345
- if ((charCode >= 65 && charCode <= 90) || // A-Z
346
- (charCode >= 97 && charCode <= 122) || // a-z
347
- (charCode >= 48 && charCode <= 57) || // 0-9
348
- charCode === 95) { // _
349
- this.advance();
350
- } else {
351
- break;
352
- }
353
- }
291
+ this.scanIdentifierBody();
354
292
 
355
293
  const value = this.input.substring(start, this.position);
356
294
  return this.createToken(TokenType.IDENTIFIER, value, start, this.position, startLine, startColumn);
@@ -361,28 +299,10 @@ export class Lexer {
361
299
  const startLine = this.line;
362
300
  const startColumn = this.column;
363
301
 
364
- this.advance(); // Skip opening `
365
-
366
- while (this.position < this.input.length) {
367
- const char = this.current();
368
-
369
- if (char === '`') {
370
- this.advance(); // Skip closing `
371
- const value = this.input.substring(start, this.position);
372
- return this.createToken(TokenType.IDENTIFIER, value, start, this.position, startLine, startColumn);
373
- }
374
-
375
- if (char === '\\') {
376
- this.advance(); // Skip escape character
377
- if (this.position >= this.input.length) {
378
- throw this.error('Unterminated delimited identifier');
379
- }
380
- }
381
-
382
- this.advance();
383
- }
384
-
385
- throw this.error('Unterminated delimited identifier');
302
+ // Current must be opening backtick
303
+ this.scanQuoted('`', 'Unterminated delimited identifier');
304
+ const value = this.input.substring(start, this.position);
305
+ return this.createToken(TokenType.IDENTIFIER, value, start, this.position, startLine, startColumn);
386
306
  }
387
307
 
388
308
  private readSpecialIdentifier(): Token {
@@ -392,18 +312,8 @@ export class Lexer {
392
312
 
393
313
  this.advance(); // Skip $
394
314
 
395
- // Read the identifier part
396
- while (this.position < this.input.length) {
397
- const charCode = this.input.charCodeAt(this.position);
398
- if ((charCode >= 65 && charCode <= 90) || // A-Z
399
- (charCode >= 97 && charCode <= 122) || // a-z
400
- (charCode >= 48 && charCode <= 57) || // 0-9
401
- charCode === 95) { // _
402
- this.advance();
403
- } else {
404
- break;
405
- }
406
- }
315
+ // Read the identifier part (may be empty)
316
+ this.scanIdentifierBody();
407
317
 
408
318
  const value = this.input.substring(start, this.position);
409
319
 
@@ -423,108 +333,36 @@ export class Lexer {
423
333
 
424
334
  if (char === '`') {
425
335
  // Delimited identifier: %`identifier`
426
- this.advance(); // Skip opening `
427
-
428
- while (this.position < this.input.length) {
429
- const ch = this.current();
430
-
431
- if (ch === '`') {
432
- this.advance(); // Skip closing `
433
- const value = this.input.substring(start, this.position);
434
- return this.createToken(TokenType.ENVIRONMENT_VARIABLE, value, start, this.position, startLine, startColumn);
435
- }
436
-
437
- if (ch === '\\') {
438
- this.advance(); // Skip escape character
439
- if (this.position >= this.input.length) {
440
- throw this.error('Unterminated environment variable');
441
- }
442
- }
443
-
444
- this.advance();
445
- }
446
-
447
- throw this.error('Unterminated environment variable');
336
+ this.scanQuoted('`', 'Unterminated environment variable');
337
+ const value = this.input.substring(start, this.position);
338
+ return this.createToken(TokenType.ENVIRONMENT_VARIABLE, value, start, this.position, startLine, startColumn);
448
339
  } else if (char === "'") {
449
340
  // String format (backwards compatibility): %'identifier'
450
- this.advance(); // Skip opening '
451
-
452
- while (this.position < this.input.length) {
453
- const ch = this.current();
454
-
455
- if (ch === "'") {
456
- this.advance(); // Skip closing '
457
- const value = this.input.substring(start, this.position);
458
- return this.createToken(TokenType.ENVIRONMENT_VARIABLE, value, start, this.position, startLine, startColumn);
459
- }
460
-
461
- if (ch === '\\') {
462
- this.advance(); // Skip escape character
463
- if (this.position >= this.input.length) {
464
- throw this.error('Unterminated environment variable');
465
- }
466
- }
467
-
468
- this.advance();
469
- }
470
-
471
- throw this.error('Unterminated environment variable');
341
+ this.scanQuoted("'", 'Unterminated environment variable');
342
+ const value = this.input.substring(start, this.position);
343
+ return this.createToken(TokenType.ENVIRONMENT_VARIABLE, value, start, this.position, startLine, startColumn);
472
344
  } else {
473
345
  // Simple identifier: %identifier
474
346
  const charCode = this.input.charCodeAt(this.position);
475
- if (!((charCode >= 65 && charCode <= 90) || // A-Z
476
- (charCode >= 97 && charCode <= 122) || // a-z
477
- charCode === 95)) { // _
347
+ if (!this.isIdentifierHead(charCode)) {
478
348
  throw this.error('Invalid environment variable name');
479
349
  }
480
-
481
- // Read the identifier part
482
- while (this.position < this.input.length) {
483
- const charCode = this.input.charCodeAt(this.position);
484
- if ((charCode >= 65 && charCode <= 90) || // A-Z
485
- (charCode >= 97 && charCode <= 122) || // a-z
486
- (charCode >= 48 && charCode <= 57) || // 0-9
487
- charCode === 95) { // _
488
- this.advance();
489
- } else {
490
- break;
491
- }
492
- }
350
+ // Consume head and the rest of identifier
351
+ this.advance();
352
+ this.scanIdentifierBody();
493
353
 
494
354
  const value = this.input.substring(start, this.position);
495
355
  return this.createToken(TokenType.ENVIRONMENT_VARIABLE, value, start, this.position, startLine, startColumn);
496
356
  }
497
357
  }
498
358
 
499
- private readString(quote: string): Token {
359
+ private readString(quote: "'" | '"'): Token {
500
360
  const start = this.position;
501
361
  const startLine = this.line;
502
362
  const startColumn = this.column;
503
-
504
- this.advance(); // Skip opening quote
505
-
506
- while (this.position < this.input.length) {
507
- const char = this.current();
508
-
509
- if (char === quote) {
510
- this.advance(); // Skip closing quote
511
- const value = this.input.substring(start, this.position);
512
- return this.createToken(TokenType.STRING, value, start, this.position, startLine, startColumn);
513
- }
514
-
515
- if (char === '\\') {
516
- this.advance(); // Skip escape character
517
- if (this.position >= this.input.length) {
518
- throw this.error('Unterminated string');
519
- }
520
- // Skip the escaped character
521
- this.advance();
522
- } else {
523
- this.advance();
524
- }
525
- }
526
-
527
- throw this.error('Unterminated string');
363
+ this.scanQuoted(quote, 'Unterminated string');
364
+ const value = this.input.substring(start, this.position);
365
+ return this.createToken(TokenType.STRING, value, start, this.position, startLine, startColumn);
528
366
  }
529
367
 
530
368
  private readNumber(): Token {
@@ -545,6 +383,31 @@ export class Lexer {
545
383
  }
546
384
  }
547
385
 
386
+ // Look ahead for a single-quoted unit to form a Quantity literal
387
+ // Pattern: <number>[ \t]?'<unit>' (no newline between number and unit)
388
+ const i = this.position;
389
+ let j = i;
390
+ // allow spaces/tabs only; do not cross lines
391
+ while (j < this.input.length) {
392
+ const ch = this.input[j]!;
393
+ if (ch === ' ' || ch === '\t') {
394
+ j++;
395
+ continue;
396
+ }
397
+ break;
398
+ }
399
+
400
+ if (j < this.input.length && this.input[j] === "'") {
401
+ // We will consume optional spaces/tabs and the quoted unit, then emit QUANTITY token
402
+ while (this.position < j) {
403
+ this.advance();
404
+ }
405
+ // Current is the opening quote; scan quoted unit including escapes
406
+ this.scanQuoted("'", 'Unterminated string');
407
+ const quantityValue = this.input.substring(start, this.position);
408
+ return this.createToken(TokenType.QUANTITY, quantityValue, start, this.position, startLine, startColumn);
409
+ }
410
+
548
411
  const value = this.input.substring(start, this.position);
549
412
  return this.createToken(TokenType.NUMBER, value, start, this.position, startLine, startColumn);
550
413
  }
@@ -574,6 +437,8 @@ export class Lexer {
574
437
  this.advance();
575
438
  }
576
439
 
440
+ let hasTime = false;
441
+
577
442
  // Optional month, day, time parts
578
443
  if (this.current() === '-') {
579
444
  this.advance();
@@ -597,17 +462,31 @@ export class Lexer {
597
462
  }
598
463
  }
599
464
 
600
- // Optional time part
465
+ // Check for time part or T suffix
601
466
  if (this.current() === 'T') {
467
+ hasTime = true;
602
468
  this.advance();
603
- this.readTimeFormat();
469
+ // Check if there's actual time content after T
470
+ if (this.isDigit(this.current())) {
471
+ this.readTimeFormat();
472
+ }
473
+ // else: it's just a T suffix like @2020T or @2020-01T
604
474
  }
605
475
 
606
- // Optional timezone
607
- this.readTimezone();
476
+ // Optional timezone (only if we have time component with actual time values)
477
+ if (hasTime && this.position > 0 && this.input[this.position - 1] !== 'T') {
478
+ // Only read timezone if there's actual time content after T
479
+ this.readTimezone();
480
+ }
608
481
 
609
482
  const value = this.input.substring(start, this.position);
610
- return this.createToken(TokenType.DATETIME, value, start, this.position, startLine, startColumn);
483
+
484
+ // Determine token type based on content
485
+ // DateTime: has 'T' anywhere
486
+ // Date: no 'T' at all
487
+ const tokenType = hasTime ? TokenType.DATETIME : TokenType.DATE;
488
+
489
+ return this.createToken(tokenType, value, start, this.position, startLine, startColumn);
611
490
  }
612
491
 
613
492
  private readTime(start: number, startLine: number, startColumn: number): Token {
@@ -648,11 +527,11 @@ export class Lexer {
648
527
  }
649
528
 
650
529
  // Optional milliseconds
651
- if (this.current() === '.') {
530
+ // Only consume the period if it's followed by a digit
531
+ // This prevents consuming periods that are method calls like .toDate()
532
+ if (this.current() === '.' && this.position + 1 < this.input.length &&
533
+ this.isDigit(this.input[this.position + 1]!)) {
652
534
  this.advance();
653
- if (!this.isDigit(this.current())) {
654
- throw this.error('Invalid time format');
655
- }
656
535
  while (this.isDigit(this.current())) {
657
536
  this.advance();
658
537
  }
@@ -686,6 +565,82 @@ export class Lexer {
686
565
  }
687
566
  }
688
567
  }
568
+
569
+ // Operator utilities
570
+ private static readonly OPERATORS: readonly string[] = [
571
+ '!=', '!~', '<=', '>=',
572
+ '+', '-', '*', '/', '<', '>', '=', '~', '|', '&',
573
+ ];
574
+
575
+ private isOperatorStarter(ch: string): boolean {
576
+ switch (ch) {
577
+ case '+':
578
+ case '-':
579
+ case '*':
580
+ case '/':
581
+ case '<':
582
+ case '>':
583
+ case '=':
584
+ case '!':
585
+ case '~':
586
+ case '|':
587
+ case '&':
588
+ return true;
589
+ default:
590
+ return false;
591
+ }
592
+ }
593
+
594
+ private readOperator(start: number, startLine: number, startColumn: number): Token {
595
+ // Try longest-first
596
+ for (const op of Lexer.OPERATORS) {
597
+ const end = start + op.length;
598
+ if (this.input.startsWith(op, start)) {
599
+ // Advance to end and emit
600
+ while (this.position < end) {
601
+ this.advance();
602
+ }
603
+ return this.createToken(TokenType.OPERATOR, op, start, this.position, startLine, startColumn);
604
+ }
605
+ }
606
+ // Special-case: lone '!'
607
+ if (this.input[start] === '!') {
608
+ // consume '!' to keep position consistent with old behavior before throwing?
609
+ // Old code threw without consuming, but tests only assert message content.
610
+ // Preserve position by not advancing.
611
+ throw this.error(`Unexpected character '!' at position ${start}`);
612
+ }
613
+ // Fallback: treat as unexpected
614
+ const ch = this.input[start] ?? '';
615
+ throw this.error(`Unexpected character '${ch}' at position ${start}`);
616
+ }
617
+
618
+ // Generic quoted scanner used by strings, delimited identifiers, env vars, and quantity units
619
+ private scanQuoted(quote: "'" | '"' | '`', unterminatedMessage: string): void {
620
+ if (this.current() !== quote) {
621
+ throw this.error('Internal error: scanQuoted called at non-quote');
622
+ }
623
+ // Skip opening quote
624
+ this.advance();
625
+ while (this.position < this.input.length) {
626
+ const ch = this.current();
627
+ if (ch === quote) {
628
+ this.advance(); // Skip closing quote
629
+ return;
630
+ }
631
+ if (ch === '\\') {
632
+ this.advance();
633
+ if (this.position >= this.input.length) {
634
+ throw this.error(unterminatedMessage);
635
+ }
636
+ // Skip escaped character
637
+ this.advance();
638
+ continue;
639
+ }
640
+ this.advance();
641
+ }
642
+ throw this.error(unterminatedMessage);
643
+ }
689
644
 
690
645
  private skipWhitespace(): void {
691
646
  while (this.position < this.input.length) {
@@ -733,8 +688,6 @@ export class Lexer {
733
688
  if (char === '\n') {
734
689
  this.line++;
735
690
  this.column = 1;
736
- this.lspLine++;
737
- this.lspCharacter = 0;
738
691
  } else if (char === '\r') {
739
692
  // Handle \r\n as single line ending
740
693
  if (this.position + 1 < this.input.length && this.input[this.position + 1] === '\n') {
@@ -743,12 +696,9 @@ export class Lexer {
743
696
  // Standalone \r
744
697
  this.line++;
745
698
  this.column = 1;
746
- this.lspLine++;
747
- this.lspCharacter = 0;
748
699
  }
749
700
  } else {
750
701
  this.column++;
751
- this.lspCharacter++;
752
702
  }
753
703
  }
754
704
  this.position++;
@@ -773,6 +723,28 @@ export class Lexer {
773
723
  if (!char) return false;
774
724
  return char === ' ' || char === '\t' || char === '\n' || char === '\r';
775
725
  }
726
+
727
+ // Identifier helpers (ASCII-based)
728
+ private isIdentifierHead(code: number): boolean {
729
+ return (code >= 65 && code <= 90) || // A-Z
730
+ (code >= 97 && code <= 122) || // a-z
731
+ code === 95; // _
732
+ }
733
+
734
+ private isIdentifierPart(code: number): boolean {
735
+ return this.isIdentifierHead(code) || (code >= 48 && code <= 57); // 0-9
736
+ }
737
+
738
+ private scanIdentifierBody(): void {
739
+ while (this.position < this.input.length) {
740
+ const code = this.input.charCodeAt(this.position);
741
+ if (this.isIdentifierPart(code)) {
742
+ this.advance();
743
+ } else {
744
+ break;
745
+ }
746
+ }
747
+ }
776
748
 
777
749
  /**
778
750
  * Convert absolute offset to LSP Position
@@ -833,6 +805,12 @@ export class Lexer {
833
805
  }
834
806
 
835
807
  private error(message: string): Error {
808
+ if (this.options.trackPosition) {
809
+ const pos = this.offsetToPosition(this.position);
810
+ const line = pos.line + 1; // present as 1-based
811
+ const col = pos.character + 1; // present as 1-based
812
+ return new Error(`Lexer error at ${line}:${col}: ${message}`);
813
+ }
836
814
  return new Error(`Lexer error: ${message}`);
837
815
  }
838
816
 
@@ -878,4 +856,4 @@ export function isOperatorValue(token: Token, value: string): boolean {
878
856
  // Helper to check if a token is an environment variable
879
857
  export function isEnvironmentVariable(token: Token): boolean {
880
858
  return token.type === TokenType.ENVIRONMENT_VARIABLE;
881
- }
859
+ }