@atomic-ehr/fhirpath 0.0.3 → 0.0.4

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 (67) hide show
  1. package/dist/index.browser.d.ts +22 -0
  2. package/dist/index.browser.js +15758 -0
  3. package/dist/index.browser.js.map +1 -0
  4. package/dist/index.node.d.ts +24 -0
  5. package/dist/{index.js → index.node.js} +5450 -3809
  6. package/dist/index.node.js.map +1 -0
  7. package/dist/{index.d.ts → model-provider.common-oir-zg7r.d.ts} +81 -74
  8. package/package.json +10 -5
  9. package/src/analyzer.ts +46 -9
  10. package/src/complex-types/quantity-value.ts +131 -9
  11. package/src/complex-types/temporal.ts +45 -6
  12. package/src/errors.ts +4 -0
  13. package/src/index.browser.ts +4 -0
  14. package/src/{index.ts → index.common.ts} +18 -14
  15. package/src/index.node.ts +4 -0
  16. package/src/interpreter/navigator.ts +12 -0
  17. package/src/interpreter/runtime-context.ts +60 -25
  18. package/src/interpreter.ts +118 -33
  19. package/src/lexer.ts +4 -1
  20. package/src/model-provider.browser.ts +35 -0
  21. package/src/{model-provider.ts → model-provider.common.ts} +29 -26
  22. package/src/model-provider.node.ts +41 -0
  23. package/src/operations/allTrue-function.ts +6 -10
  24. package/src/operations/and-operator.ts +2 -2
  25. package/src/operations/as-function.ts +41 -0
  26. package/src/operations/combine-operator.ts +17 -4
  27. package/src/operations/comparison.ts +73 -21
  28. package/src/operations/convertsToQuantity-function.ts +56 -7
  29. package/src/operations/decode-function.ts +114 -0
  30. package/src/operations/divide-operator.ts +3 -3
  31. package/src/operations/encode-function.ts +110 -0
  32. package/src/operations/escape-function.ts +114 -0
  33. package/src/operations/exp-function.ts +65 -0
  34. package/src/operations/extension-function.ts +88 -0
  35. package/src/operations/greater-operator.ts +5 -24
  36. package/src/operations/greater-or-equal-operator.ts +5 -24
  37. package/src/operations/hasValue-function.ts +84 -0
  38. package/src/operations/iif-function.ts +7 -1
  39. package/src/operations/implies-operator.ts +1 -0
  40. package/src/operations/index.ts +11 -0
  41. package/src/operations/is-function.ts +11 -0
  42. package/src/operations/is-operator.ts +187 -5
  43. package/src/operations/less-operator.ts +6 -24
  44. package/src/operations/less-or-equal-operator.ts +5 -24
  45. package/src/operations/less-than.ts +7 -12
  46. package/src/operations/ln-function.ts +62 -0
  47. package/src/operations/log-function.ts +113 -0
  48. package/src/operations/lowBoundary-function.ts +14 -0
  49. package/src/operations/minus-operator.ts +8 -1
  50. package/src/operations/mod-operator.ts +7 -1
  51. package/src/operations/not-function.ts +9 -2
  52. package/src/operations/ofType-function.ts +35 -0
  53. package/src/operations/plus-operator.ts +46 -3
  54. package/src/operations/precision-function.ts +146 -0
  55. package/src/operations/replace-function.ts +19 -19
  56. package/src/operations/replaceMatches-function.ts +5 -0
  57. package/src/operations/sort-function.ts +209 -0
  58. package/src/operations/take-function.ts +1 -1
  59. package/src/operations/toQuantity-function.ts +0 -1
  60. package/src/operations/toString-function.ts +76 -12
  61. package/src/operations/trace-function.ts +20 -3
  62. package/src/operations/unescape-function.ts +119 -0
  63. package/src/operations/where-function.ts +3 -1
  64. package/src/parser.ts +14 -2
  65. package/src/types.ts +7 -5
  66. package/src/utils/decimal.ts +76 -0
  67. package/dist/index.js.map +0 -1
@@ -1,7 +1,14 @@
1
1
  import type { ModelProvider, TypeInfo, TypeName } from './types';
2
- import { CanonicalManager as createCanonicalManager, type Config, type CanonicalManager, type Resource } from '@atomic-ehr/fhir-canonical-manager';
3
2
  import { translate, type FHIRSchema, type StructureDefinition } from '@atomic-ehr/fhirschema';
4
3
 
4
+ export type Resource = {
5
+ url?: string;
6
+ version?: string;
7
+ id: string;
8
+ resourceType: string;
9
+ [key: string]: any;
10
+ };
11
+
5
12
  export interface FHIRModelContext {
6
13
  // Path in the resource (e.g., "Patient.name.given")
7
14
  path: string;
@@ -23,12 +30,6 @@ export interface FHIRModelContext {
23
30
  version?: string;
24
31
  }
25
32
 
26
- export interface FHIRModelProviderConfig {
27
- packages: Array<{ name: string; version: string }>;
28
- cacheDir?: string;
29
- registryUrl?: string;
30
- }
31
-
32
33
  /**
33
34
  * FHIR ModelProvider implementation
34
35
  *
@@ -37,8 +38,7 @@ export interface FHIRModelProviderConfig {
37
38
  *
38
39
  * For best performance, pre-load common types during initialization.
39
40
  */
40
- export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
41
- private canonicalManager: ReturnType<typeof createCanonicalManager>;
41
+ export class FHIRModelProviderBase implements ModelProvider<FHIRModelContext> {
42
42
  private schemaCache: Map<string, FHIRSchema> = new Map();
43
43
  private hierarchyCache: Map<string, FHIRSchema[]> = new Map();
44
44
  private initialized = false;
@@ -105,26 +105,21 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
105
105
  'xhtml': 'Xhtml'
106
106
  };
107
107
 
108
- constructor(private config: FHIRModelProviderConfig = {
109
- packages: [{ name: 'hl7.fhir.r4.core', version: '4.0.1' }]
110
- }) {
111
- const canonicalConfig: Config = {
112
- packages: config.packages.map(p => `${p.name}@${p.version}`),
113
- workingDir: config.cacheDir || './tmp/.fhir-cache'
114
- };
115
-
116
- if (config.registryUrl) {
117
- canonicalConfig.registry = config.registryUrl;
108
+ constructor() {
109
+ if (this.constructor === FHIRModelProviderBase) {
110
+ throw new Error("FHIRModelProviderBase can't be instantiated directly.");
118
111
  }
119
-
120
- this.canonicalManager = createCanonicalManager(canonicalConfig);
112
+ }
113
+
114
+ async prepare(): Promise<void> {
115
+ // Override this in your code
121
116
  }
122
117
 
123
118
  async initialize(): Promise<void> {
124
119
  if (this.initialized) return;
125
120
 
126
121
  try {
127
- await this.canonicalManager.init();
122
+ await this.prepare();
128
123
 
129
124
  // Just discover type names for completions - schemas load lazily on demand
130
125
  await Promise.all([
@@ -142,6 +137,14 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
142
137
  }
143
138
  }
144
139
 
140
+ async resolve(_canonicalUrl: string): Promise<Resource | null> {
141
+ throw new Error("Resolve not implemented.")
142
+ }
143
+
144
+ async search(_params: {kind: "primitive-type" | "complex-type" | "resource"}): Promise<Resource[]> {
145
+ throw new Error("Search not implemented.")
146
+ }
147
+
145
148
  private buildCanonicalUrl(typeName: string): string {
146
149
  // For R4 core types
147
150
  return `http://hl7.org/fhir/StructureDefinition/${typeName}`;
@@ -157,7 +160,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
157
160
  try {
158
161
  // Resolve canonical URL for the type
159
162
  const canonicalUrl = this.buildCanonicalUrl(typeName);
160
- const resource = await this.canonicalManager.resolve(canonicalUrl);
163
+ const resource = await this.resolve(canonicalUrl);
161
164
  if (!resource || resource.resourceType !== 'StructureDefinition') {
162
165
  return undefined;
163
166
  }
@@ -614,7 +617,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
614
617
  return this.resourceTypesCache;
615
618
  }
616
619
 
617
- const resources = await this.canonicalManager.search({
620
+ const resources = await this.search({
618
621
  kind: 'resource'
619
622
  });
620
623
 
@@ -632,7 +635,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
632
635
  return this.complexTypesCache;
633
636
  }
634
637
 
635
- const resources = await this.canonicalManager.search({
638
+ const resources = await this.search({
636
639
  kind: 'complex-type'
637
640
  });
638
641
 
@@ -658,7 +661,7 @@ export class FHIRModelProvider implements ModelProvider<FHIRModelContext> {
658
661
  return this.primitiveTypesCache;
659
662
  }
660
663
 
661
- const resources = await this.canonicalManager.search({
664
+ const resources = await this.search({
662
665
  kind: 'primitive-type'
663
666
  });
664
667
 
@@ -0,0 +1,41 @@
1
+ export * from './model-provider.common'
2
+ import { CanonicalManager as createCanonicalManager, type Config, } from '@atomic-ehr/fhir-canonical-manager';
3
+ import { FHIRModelProviderBase, type Resource } from './model-provider.common';
4
+
5
+ export interface FHIRModelProviderConfig {
6
+ packages: Array<{ name: string; version: string }>;
7
+ cacheDir?: string;
8
+ registryUrl?: string;
9
+ }
10
+
11
+ export class FHIRModelProvider extends FHIRModelProviderBase {
12
+ private canonicalManager: ReturnType<typeof createCanonicalManager>;
13
+
14
+ override async prepare(): Promise<void> {
15
+ await this.canonicalManager.init();
16
+ }
17
+
18
+ override async resolve(canonicalUrl: string): Promise<Resource> {
19
+ return await this.canonicalManager.resolve(canonicalUrl);
20
+ }
21
+
22
+ override async search(params: { kind: 'primitive-type' | 'complex-type' | 'resource' }): Promise<Resource[]> {
23
+ return await this.canonicalManager.search(params);
24
+ }
25
+
26
+ constructor(private config: FHIRModelProviderConfig = {
27
+ packages: [{ name: 'hl7.fhir.r4.core', version: '4.0.1' }]
28
+ }) {
29
+ super();
30
+ const canonicalConfig: Config = {
31
+ packages: config.packages.map(p => `${p.name}@${p.version}`),
32
+ workingDir: config.cacheDir || './tmp/.fhir-cache'
33
+ };
34
+
35
+ if (config.registryUrl) {
36
+ canonicalConfig.registry = config.registryUrl;
37
+ }
38
+
39
+ this.canonicalManager = createCanonicalManager(canonicalConfig);
40
+ }
41
+ }
@@ -8,16 +8,12 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
8
8
  return { value: [box(true, { type: 'Boolean', singleton: true })], context };
9
9
  }
10
10
 
11
- // Verify all inputs are booleans (unbox first)
12
- for (let i = 0; i < input.length; i++) {
13
- const unboxedValue = unbox(input[i]!);
14
- if (typeof unboxedValue !== 'boolean') {
15
- throw Errors.booleanOperationOnNonBoolean('allTrue', i, `${typeof unboxedValue}`);
16
- }
17
- }
18
-
19
- // Return true if all items are true, false if any item is false
20
- const result = input.every(item => unbox(item) === true);
11
+ // Check all items: return false if any item is non-boolean or false
12
+ // Return true only if ALL items are boolean true
13
+ const result = input.every(item => {
14
+ const unboxedValue = unbox(item);
15
+ return unboxedValue === true; // This will return false for non-booleans or false values
16
+ });
21
17
 
22
18
  return { value: [box(result, { type: 'Boolean', singleton: true })], context };
23
19
  };
@@ -39,8 +39,8 @@ export const andOperator: OperatorDefinition & { evaluate: OperationEvaluator }
39
39
  signatures: [
40
40
  {
41
41
  name: 'and',
42
- left: { type: 'Boolean', singleton: true },
43
- right: { type: 'Boolean', singleton: true },
42
+ left: { type: 'Any', singleton: true },
43
+ right: { type: 'Any', singleton: true },
44
44
  result: { type: 'Boolean', singleton: true }
45
45
  }
46
46
  ],
@@ -19,6 +19,12 @@ const asEvaluator: FunctionEvaluator = async (
19
19
  return { value: [], context };
20
20
  }
21
21
 
22
+ // as() function only works on singleton values (single items), not collections
23
+ // According to FHIRPath spec section 6.6
24
+ if (input.length > 1) {
25
+ throw new Error('as() can only be used on single values, not on collections');
26
+ }
27
+
22
28
  // Extract type name from the argument AST node
23
29
  let typeName: string;
24
30
 
@@ -29,6 +35,41 @@ const asEvaluator: FunctionEvaluator = async (
29
35
  throw new Error(`as() requires a type name as argument, got ${typeArg.type}`);
30
36
  }
31
37
 
38
+ // Validate the type name using ModelProvider if available
39
+ if (context.modelProvider) {
40
+ // Try to get the type from the model provider
41
+ const typeInfo = await context.modelProvider.getType(typeName);
42
+ if (!typeInfo) {
43
+ // Not a valid FHIR type, check if it's a System type
44
+ const systemTypes = ['Boolean', 'String', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time', 'Quantity'];
45
+ const fhirPrimitiveTypes = ['boolean', 'integer', 'string', 'decimal', 'uri', 'url', 'canonical',
46
+ 'base64Binary', 'instant', 'date', 'dateTime', 'time', 'code', 'oid',
47
+ 'id', 'markdown', 'unsignedInt', 'positiveInt', 'uuid', 'xhtml'];
48
+
49
+ if (!systemTypes.includes(typeName) && !fhirPrimitiveTypes.includes(typeName)) {
50
+ throw new Error(`Unknown type: ${typeName}`);
51
+ }
52
+ }
53
+ } else {
54
+ // Without ModelProvider, only allow basic System types and reject obvious invalid names
55
+ const systemTypes = ['Boolean', 'String', 'Integer', 'Decimal', 'Date', 'DateTime', 'Time'];
56
+ const fhirPrimitiveTypes = ['boolean', 'integer', 'string', 'decimal', 'uri', 'url', 'canonical',
57
+ 'base64Binary', 'instant', 'date', 'dateTime', 'time', 'code', 'oid',
58
+ 'id', 'markdown', 'unsignedInt', 'positiveInt', 'uuid'];
59
+
60
+ // If it's not a known primitive type and looks invalid, reject it
61
+ if (!systemTypes.includes(typeName) && !fhirPrimitiveTypes.includes(typeName)) {
62
+ // Check if it looks like a valid type name (starts with letter, contains only valid chars)
63
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(typeName) && !/^[a-z][a-z0-9]*$/i.test(typeName)) {
64
+ throw new Error(`Invalid type name: ${typeName}`);
65
+ }
66
+ // If it contains numbers but isn't a known type, it's likely invalid
67
+ if (/\d/.test(typeName) && typeName !== 'base64Binary') {
68
+ throw new Error(`Unknown type: ${typeName}`);
69
+ }
70
+ }
71
+ }
72
+
32
73
  // Use the as operator implementation with the type name
33
74
  return asOperatorEvaluate(input, context, input, [typeName]);
34
75
  };
@@ -4,10 +4,23 @@ import type { OperationEvaluator } from '../types';
4
4
  import { box, unbox } from '../interpreter/boxing';
5
5
 
6
6
  export const evaluate: OperationEvaluator = async (input, context, left, right) => {
7
- // Combine operator concatenates all values as strings
7
+ // & operator requires singleton operands (or empty)
8
8
  // Empty collections are treated as empty string
9
- const leftStr = left.length === 0 ? '' : left.map(v => String(unbox(v))).join('');
10
- const rightStr = right.length === 0 ? '' : right.map(v => String(unbox(v))).join('');
9
+
10
+ // Check for multiple items in left operand
11
+ if (left.length > 1) {
12
+ const { Errors } = await import('../errors');
13
+ throw Errors.invalidOperation('& operator requires singleton operands, left operand contains multiple items');
14
+ }
15
+
16
+ // Check for multiple items in right operand
17
+ if (right.length > 1) {
18
+ const { Errors } = await import('../errors');
19
+ throw Errors.invalidOperation('& operator requires singleton operands, right operand contains multiple items');
20
+ }
21
+
22
+ const leftStr = left.length === 0 ? '' : String(unbox(left[0]));
23
+ const rightStr = right.length === 0 ? '' : String(unbox(right[0]));
11
24
 
12
25
  // Always return a string, even if both are empty
13
26
  return { value: [box(leftStr + rightStr, { type: 'String', singleton: true })], context };
@@ -20,7 +33,7 @@ export const combineOperator: OperatorDefinition & { evaluate: OperationEvaluato
20
33
  category: ['string'],
21
34
  precedence: PRECEDENCE.ADDITIVE,
22
35
  associativity: 'left',
23
- description: 'String concatenation operator',
36
+ description: 'String concatenation operator that requires singleton operands. Empty collections are treated as empty strings.',
24
37
  examples: ['first & " " & last'],
25
38
  signatures: [
26
39
  {
@@ -142,6 +142,12 @@ export function collectionsEqual(left: FHIRPathValue[], right: FHIRPathValue[]):
142
142
  if (result.reason === 'complex types not equal') {
143
143
  return false;
144
144
  }
145
+ // Special case: Date vs Time are definitively not equal
146
+ // Per XML tests: Date != Time returns true, Date = Time returns false
147
+ if (result.reason === 'date vs time') {
148
+ return false;
149
+ }
150
+ // Other incomparable cases (e.g., Date vs DateTime with same date) remain incomparable
145
151
  return null;
146
152
  }
147
153
  return result.kind === 'equal';
@@ -173,6 +179,12 @@ export function collectionsNotEqual(left: FHIRPathValue[], right: FHIRPathValue[
173
179
  if (result.reason === 'complex types not equal') {
174
180
  return true;
175
181
  }
182
+ // Special case: Date vs Time are definitively not equal
183
+ // Per XML tests: Date != Time returns true, Date = Time returns false
184
+ if (result.reason === 'date vs time') {
185
+ return true;
186
+ }
187
+ // Other incomparable cases (e.g., Date vs DateTime with same date) remain incomparable
176
188
  return null;
177
189
  }
178
190
  return result.kind !== 'equal';
@@ -188,7 +200,15 @@ function isTemporalValue(value: unknown): value is TemporalValue {
188
200
  function isQuantity(value: unknown): value is QuantityValue {
189
201
  if (!value || typeof value !== 'object') return false;
190
202
  const v = value as any;
191
- return 'unit' in v && 'value' in v && typeof v.value === 'number' && typeof v.unit === 'string';
203
+ // Check for FHIRPath quantity (has unit and value)
204
+ if ('unit' in v && 'value' in v && typeof v.value === 'number' && typeof v.unit === 'string') {
205
+ return true;
206
+ }
207
+ // Check for FHIR Quantity (has code or unit, and value)
208
+ if ('value' in v && typeof v.value === 'number' && ('code' in v || 'unit' in v)) {
209
+ return true;
210
+ }
211
+ return false;
192
212
  }
193
213
 
194
214
  function isComplex(value: unknown): value is object {
@@ -198,6 +218,12 @@ function isComplex(value: unknown): value is object {
198
218
  // Type-specific comparison functions
199
219
 
200
220
  function compareTemporal(a: TemporalValue, b: TemporalValue): ComparisonResult {
221
+ // Check for Date vs Time - these are definitively not equal
222
+ if ((isFHIRDate(a) && isFHIRTime(b)) || (isFHIRTime(a) && isFHIRDate(b))) {
223
+ // Date and Time are completely different types - not equal, not incomparable
224
+ return { kind: 'incomparable', reason: 'date vs time' };
225
+ }
226
+
201
227
  // Use existing temporal comparison logic
202
228
  const compareResult = temporalCompare(a, b);
203
229
 
@@ -213,8 +239,20 @@ function compareTemporal(a: TemporalValue, b: TemporalValue): ComparisonResult {
213
239
  return { kind: 'greater' };
214
240
  }
215
241
 
242
+ function normalizeQuantity(q: any): QuantityValue {
243
+ // For FHIR Quantity, use code field if present, otherwise unit
244
+ if ('code' in q && typeof q.code === 'string') {
245
+ return { value: q.value, unit: q.code };
246
+ }
247
+ return { value: q.value, unit: q.unit };
248
+ }
249
+
216
250
  function compareQuantityValues(a: QuantityValue, b: QuantityValue): ComparisonResult {
217
- const result = compareQuantities(a, b);
251
+ // Normalize both quantities to handle FHIR vs FHIRPath quantities
252
+ const normA = normalizeQuantity(a);
253
+ const normB = normalizeQuantity(b);
254
+
255
+ const result = compareQuantities(normA, normB);
218
256
 
219
257
  if (result === null) {
220
258
  return { kind: 'incomparable', reason: 'incompatible quantity dimensions' };
@@ -534,31 +572,45 @@ const CALENDAR_TO_UCUM_MAP: Record<string, string> = {
534
572
  * Quantity equivalence with UCUM semantic comparison and calendar mappings
535
573
  */
536
574
  function quantityEquivalent(a: QuantityValue, b: QuantityValue): boolean | null {
537
- // Check for calendar to UCUM equivalence
538
- const aIsCalendar = CALENDAR_TO_UCUM_MAP[a.unit] !== undefined;
539
- const bIsCalendar = CALENDAR_TO_UCUM_MAP[b.unit] !== undefined;
575
+ // Normalize both quantities to handle FHIR vs FHIRPath quantities
576
+ const normA = normalizeQuantity(a);
577
+ const normB = normalizeQuantity(b);
578
+
579
+ // Use compareQuantities which now handles calendar unit conversions
580
+ const result = compareQuantities(normA, normB);
540
581
 
541
- // Calendar to UCUM mapping
542
- if (aIsCalendar && !bIsCalendar) {
543
- const ucumUnit = CALENDAR_TO_UCUM_MAP[a.unit];
544
- return ucumUnit === b.unit && a.value === b.value;
582
+ // Null means incomparable
583
+ if (result === null) {
584
+ return null;
545
585
  }
546
- if (!aIsCalendar && bIsCalendar) {
547
- const ucumUnit = CALENDAR_TO_UCUM_MAP[b.unit];
548
- return ucumUnit === a.unit && a.value === b.value;
586
+
587
+ // For equivalence, we check if they're equal when converted
588
+ // compareQuantities returns 0 for equal values
589
+ if (result === 0) {
590
+ return true;
549
591
  }
550
592
 
551
- // Both calendar units - must be same unit and value
552
- if (aIsCalendar && bIsCalendar) {
553
- // Normalize to singular/plural
554
- const aNorm = CALENDAR_TO_UCUM_MAP[a.unit];
555
- const bNorm = CALENDAR_TO_UCUM_MAP[b.unit];
556
- return aNorm === bNorm && a.value === b.value;
593
+ // For approximate equivalence (~), check if values are within tolerance
594
+ // This is primarily for handling precision differences like 4 g ~ 4.040 g
595
+ // We need special handling for common unit conversions with tolerance
596
+
597
+ // Check if one is g and other is mg
598
+ if ((a.unit === 'g' && b.unit === 'mg') || (a.unit === 'mg' && b.unit === 'g')) {
599
+ // Convert both to mg for comparison
600
+ const aInMg = a.unit === 'g' ? a.value * 1000 : a.value;
601
+ const bInMg = b.unit === 'g' ? b.value * 1000 : b.value;
602
+
603
+ // Check if within 1% tolerance for approximate equivalence
604
+ const diff = Math.abs(aInMg - bInMg);
605
+ const avg = (aInMg + bInMg) / 2;
606
+ const tolerance = avg * 0.01; // 1% tolerance
607
+
608
+ return diff <= tolerance;
557
609
  }
558
610
 
559
- // Standard UCUM semantic equivalence (1000 mg ~ 1 g)
560
- const result = compareQuantities(a, b);
561
- return result === 0;
611
+ // For other units, compareQuantities handles the conversion
612
+ // If it returned non-zero, they're not equivalent
613
+ return false;
562
614
  }
563
615
 
564
616
  /**
@@ -50,13 +50,25 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
50
50
  }
51
51
  }
52
52
 
53
- // String - check if it can be parsed as a quantity (e.g., "10 mg", "5.5 km")
53
+ // String - check if it can be parsed as a quantity or plain number
54
54
  if (typeof inputValue === 'string') {
55
+ const trimmed = inputValue.trim();
56
+
57
+ // First check if it's just a plain number (integer or decimal)
58
+ const numberRegex = /^(\+|-)?\d+(\.\d+)?$/;
59
+ if (numberRegex.test(trimmed)) {
60
+ const value = parseFloat(trimmed);
61
+ if (!isNaN(value)) {
62
+ // Plain number strings can be converted to quantity with unit '1'
63
+ return { value: [box(true, { type: 'Boolean', singleton: true })], context };
64
+ }
65
+ }
66
+
55
67
  // Try to parse as quantity: number followed by space(s) and unit
56
- // This matches the pattern: <number> <unit>
68
+ // This matches the pattern: <number> <unit> or <number> '<unit>'
57
69
  const quantityRegex = /^(\+|-)?\d+(\.\d+)?\s+.+$/;
58
70
 
59
- if (!quantityRegex.test(inputValue.trim())) {
71
+ if (!quantityRegex.test(trimmed)) {
60
72
  return { value: [box(false, { type: 'Boolean', singleton: true })], context };
61
73
  }
62
74
 
@@ -67,7 +79,40 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
67
79
  }
68
80
 
69
81
  const valueStr = parts[0];
70
- const unit = parts.slice(1).join(' ');
82
+ let unit = parts.slice(1).join(' ');
83
+
84
+ // Check if unit is quoted
85
+ const isQuoted = unit.startsWith("'") && unit.endsWith("'") && unit.length > 2;
86
+
87
+ if (isQuoted) {
88
+ // Remove quotes for validation
89
+ unit = unit.slice(1, -1);
90
+ } else {
91
+ // For unquoted units, check special cases
92
+ // Calendar duration words are always valid
93
+ const CALENDAR_DURATION_WORDS = [
94
+ 'year', 'years',
95
+ 'month', 'months',
96
+ 'week', 'weeks',
97
+ 'day', 'days',
98
+ 'hour', 'hours',
99
+ 'minute', 'minutes',
100
+ 'second', 'seconds',
101
+ 'millisecond', 'milliseconds'
102
+ ];
103
+
104
+ // Time unit abbreviations that should NOT be accepted without quotes
105
+ // (they have calendar duration equivalents)
106
+ const TIME_UNIT_ABBREVS = ['wk', 'd', 'h', 'min', 's', 'ms'];
107
+
108
+ if (TIME_UNIT_ABBREVS.includes(unit)) {
109
+ // These time abbreviations require quotes
110
+ return { value: [box(false, { type: 'Boolean', singleton: true })], context };
111
+ }
112
+
113
+ // Calendar duration words are valid
114
+ // Other units (like mg, km, etc.) are also valid
115
+ }
71
116
 
72
117
  const value = parseFloat(valueStr!);
73
118
  if (isNaN(value)) {
@@ -84,10 +129,14 @@ export const evaluate: FunctionEvaluator = async (input, context, args, evaluato
84
129
  }
85
130
  }
86
131
 
87
- // Integer or Decimal with no unit - not a valid quantity
88
- // (quantities must have units)
132
+ // Integer or Decimal - can be converted to quantity with unit '1'
89
133
  if (typeof inputValue === 'number') {
90
- return { value: [box(false, { type: 'Boolean', singleton: true })], context };
134
+ return { value: [box(true, { type: 'Boolean', singleton: true })], context };
135
+ }
136
+
137
+ // Boolean - can be converted to quantity (1 or 0 with unit '1')
138
+ if (typeof inputValue === 'boolean') {
139
+ return { value: [box(true, { type: 'Boolean', singleton: true })], context };
91
140
  }
92
141
 
93
142
  // For all other types, return false
@@ -0,0 +1,114 @@
1
+ import type { FunctionDefinition, FunctionEvaluator } from '../types';
2
+ import { Errors } from '../errors';
3
+ import { box, unbox } from '../interpreter/boxing';
4
+
5
+ export const evaluate: FunctionEvaluator = async (input, context, args, evaluator) => {
6
+ // Handle empty input collection
7
+ if (input.length === 0) {
8
+ return { value: [], context };
9
+ }
10
+
11
+ // If input contains multiple items, signal an error
12
+ if (input.length > 1) {
13
+ throw Errors.invalidOperation('decode can only be applied to a singleton string');
14
+ }
15
+
16
+ const boxedInputValue = input[0];
17
+ if (!boxedInputValue) {
18
+ return { value: [], context };
19
+ }
20
+
21
+ const inputValue = unbox(boxedInputValue);
22
+
23
+ // Type check the input - must be a string
24
+ if (typeof inputValue !== 'string') {
25
+ throw Errors.stringOperationOnNonString('decode');
26
+ }
27
+
28
+ // decode() requires exactly one argument (format)
29
+ if (!args || args.length !== 1) {
30
+ throw Errors.invalidOperation('decode requires exactly one argument (format)');
31
+ }
32
+
33
+ // Evaluate the format parameter
34
+ const formatArg = args[0];
35
+ if (!formatArg) {
36
+ return { value: [], context };
37
+ }
38
+
39
+ const formatResult = await evaluator(formatArg, input, context);
40
+ if (formatResult.value.length === 0) {
41
+ // If no format is specified, the result is empty
42
+ return { value: [], context };
43
+ }
44
+
45
+ if (formatResult.value.length > 1) {
46
+ throw Errors.invalidOperation('decode format must be a singleton string');
47
+ }
48
+
49
+ const boxedFormat = formatResult.value[0];
50
+ if (!boxedFormat) {
51
+ return { value: [], context };
52
+ }
53
+
54
+ const format = unbox(boxedFormat);
55
+ if (typeof format !== 'string') {
56
+ throw Errors.invalidOperation('decode format must be a string');
57
+ }
58
+
59
+ let result: string;
60
+
61
+ try {
62
+ switch (format.toLowerCase()) {
63
+ case 'hex': {
64
+ // Decode from hexadecimal
65
+ const bytes = Buffer.from(inputValue, 'hex');
66
+ result = bytes.toString('utf-8');
67
+ break;
68
+ }
69
+ case 'base64': {
70
+ // Standard base64 decoding
71
+ const bytes = Buffer.from(inputValue, 'base64');
72
+ result = bytes.toString('utf-8');
73
+ break;
74
+ }
75
+ case 'urlbase64': {
76
+ // URL-safe base64 decoding
77
+ // First, convert urlbase64 to standard base64 format if needed
78
+ // Replace URL-safe characters with standard base64 characters
79
+ const standardBase64 = inputValue.replace(/-/g, '+').replace(/_/g, '/');
80
+ const bytes = Buffer.from(standardBase64, 'base64');
81
+ result = bytes.toString('utf-8');
82
+ break;
83
+ }
84
+ default:
85
+ // Unknown format, return empty
86
+ return { value: [], context };
87
+ }
88
+ } catch (error) {
89
+ // If decoding fails (invalid encoding), throw an error
90
+ throw Errors.invalidOperation(`Failed to decode string with format '${format}': invalid encoding`);
91
+ }
92
+
93
+ return { value: [box(result, { type: 'String', singleton: true })], context };
94
+ };
95
+
96
+ export const decodeFunction: FunctionDefinition & { evaluate: FunctionEvaluator } = {
97
+ name: 'decode',
98
+ category: ['string'],
99
+ description: 'Returns the result of decoding the input string according to the given format. Available formats are hex, base64, and urlbase64.',
100
+ examples: [
101
+ "'dGVzdA=='.decode('base64') // returns 'test'",
102
+ "'74657374'.decode('hex') // returns 'test'",
103
+ "'c3ViamVjdHM_X2Q='.decode('urlbase64') // returns 'subjects?_d'"
104
+ ],
105
+ signatures: [{
106
+ name: 'decode',
107
+ input: { type: 'String', singleton: true },
108
+ parameters: [
109
+ { name: 'format', type: { type: 'String', singleton: true } }
110
+ ],
111
+ result: { type: 'String', singleton: true }
112
+ }],
113
+ evaluate
114
+ };
@@ -23,7 +23,7 @@ export const evaluate: OperationEvaluator = async (input, context, left, right)
23
23
  r && typeof r === 'object' && 'unit' in r) {
24
24
  const rightQuantity = r as QuantityValue;
25
25
  if (rightQuantity.value === 0) {
26
- throw Errors.divisionByZero();
26
+ return { value: [], context }; // Return empty for division by zero
27
27
  }
28
28
  const result = divideQuantities(l as QuantityValue, rightQuantity);
29
29
  return { value: result ? [box(result, { type: 'Quantity', singleton: true })] : [], context };
@@ -32,7 +32,7 @@ export const evaluate: OperationEvaluator = async (input, context, left, right)
32
32
  // Handle quantity / number
33
33
  if (l && typeof l === 'object' && 'unit' in l && typeof r === 'number') {
34
34
  if (r === 0) {
35
- throw Errors.divisionByZero();
35
+ return { value: [], context }; // Return empty for division by zero
36
36
  }
37
37
  const q = l as QuantityValue;
38
38
  return { value: [box({ value: q.value / r, unit: q.unit }, { type: 'Quantity', singleton: true })], context };
@@ -41,7 +41,7 @@ export const evaluate: OperationEvaluator = async (input, context, left, right)
41
41
  // Handle numeric division
42
42
  if (typeof l === 'number' && typeof r === 'number') {
43
43
  if (r === 0) {
44
- throw Errors.divisionByZero();
44
+ return { value: [], context }; // Return empty for division by zero
45
45
  }
46
46
  return { value: [box(l / r, { type: 'Any', singleton: true })], context };
47
47
  }