fhirsmith 0.17.0 → 0.18.0

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 (121) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +422 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +7 -2
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +9 -0
  89. package/tx/README.md +52 -0
  90. package/tx/cs/cs-api.js +90 -14
  91. package/tx/cs/cs-cs.js +56 -1
  92. package/tx/cs/cs-icd11.js +366 -112
  93. package/tx/cs/cs-icf.js +418 -0
  94. package/tx/cs/cs-loinc.js +47 -25
  95. package/tx/cs/cs-omop.js +10 -3
  96. package/tx/cs/cs-snomed.js +918 -55
  97. package/tx/cs/cs-ucum.js +6 -0
  98. package/tx/cs/icd11-expressions.js +191 -78
  99. package/tx/importers/export-icd11-codesystem.js +58 -12
  100. package/tx/importers/icd11-schema.md +9 -5
  101. package/tx/library.js +98 -4
  102. package/tx/openapi-operations.js +4 -0
  103. package/tx/params.js +8 -1
  104. package/tx/provider.js +16 -4
  105. package/tx/sct/documentation.md +251 -0
  106. package/tx/sct/ecl.js +758 -46
  107. package/tx/sct/expressions.js +113 -52
  108. package/tx/syndication/syndication-feed.js +218 -0
  109. package/tx/syndication/syndication-store.js +415 -0
  110. package/tx/tx-html.js +15 -1
  111. package/tx/tx.fhir.org.yml +2 -3
  112. package/tx/tx.js +144 -2
  113. package/tx/vs/vs-vsac.js +25 -6
  114. package/tx/workers/expand.js +33 -6
  115. package/tx/workers/lookup.js +61 -6
  116. package/tx/workers/subsumes.js +1 -1
  117. package/tx/workers/translate.js +166 -15
  118. package/tx/workers/validate.js +128 -6
  119. package/utilities/make-sct-test-extension.js +264 -0
  120. package/xig/xig.js +32 -0
  121. package/registry/readme.md +0 -124
package/tx/sct/ecl.js CHANGED
@@ -4,7 +4,10 @@
4
4
  * This implementation provides parsing, validation, and evaluation of ECL expressions
5
5
  * against SNOMED CT data using the existing SnomedServices infrastructure.
6
6
  *
7
- * Supports ECL v2.1 specification from SNOMED International
7
+ * Supports the ECL v2.2 grammar from SNOMED International (the parser); which
8
+ * constructs are evaluated is documented on SnomedServices.filterECL in
9
+ * cs-snomed.js. The ECLValidator evaluate* methods in this file are a legacy
10
+ * evaluator that the server does not use.
8
11
  */
9
12
 
10
13
  // ECL Token Types
@@ -25,6 +28,8 @@ const ECLTokenType = {
25
28
  PARENT_OR_SELF_OF: 'PARENT_OR_SELF_OF', // >>! self + direct parents
26
29
  ANCESTOR_OF: 'ANCESTOR_OF', // > all transitive ancestors, no self
27
30
  ANCESTOR_OR_SELF_OF: 'ANCESTOR_OR_SELF_OF', // >> self + all transitive ancestors
31
+ TOP_OF_SET: 'TOP_OF_SET', // !!> members of the set with no ancestor in the set
32
+ BOTTOM_OF_SET: 'BOTTOM_OF_SET', // !!< members of the set with no descendant in the set
28
33
 
29
34
  // Set operators
30
35
  AND: 'AND',
@@ -54,6 +59,8 @@ const ECLTokenType = {
54
59
  RPAREN: 'RPAREN', // )
55
60
  LBRACE: 'LBRACE', // {
56
61
  RBRACE: 'RBRACE', // }
62
+ LFILTER: 'LFILTER', // {{ (filter constraint open)
63
+ RFILTER: 'RFILTER', // }} (filter constraint close)
57
64
  LBRACKET: 'LBRACKET', // [
58
65
  RBRACKET: 'RBRACKET', // ]
59
66
 
@@ -61,6 +68,7 @@ const ECLTokenType = {
61
68
  COMMA: 'COMMA', // ,
62
69
  PIPE: 'PIPE', // |
63
70
  HASH: 'HASH', // #
71
+ PLUS: 'PLUS', // + (history supplement)
64
72
 
65
73
  // Special tokens
66
74
  WS: 'WS',
@@ -78,6 +86,10 @@ const ECLNodeType = {
78
86
  CONCEPT_REFERENCE: 'ConceptReference',
79
87
  WILDCARD: 'Wildcard',
80
88
  MEMBER_OF: 'MemberOf',
89
+ MEMBER_FILTER: 'MemberFilter', // one {{ M ... }} term
90
+ CONCEPT_FILTER: 'ConceptFilter', // one {{ C ... }} term
91
+ DESCRIPTION_FILTER: 'DescriptionFilter', // one {{ D ... }} term
92
+ FILTER_CONSTRAINT: 'FilterConstraint', // a {{ D ... }} / {{ C ... }} / {{ + ... }} block (parsed, not evaluated)
81
93
 
82
94
  REFINEMENT: 'Refinement',
83
95
  ATTRIBUTE_SET: 'AttributeSet',
@@ -88,6 +100,8 @@ const ECLNodeType = {
88
100
  EXPRESSION_COMPARISON: 'ExpressionComparison',
89
101
  NUMERIC_COMPARISON: 'NumericComparison',
90
102
  STRING_COMPARISON: 'StringComparison',
103
+ BOOLEAN_COMPARISON: 'BooleanComparison',
104
+ ALT_IDENTIFIER: 'AltIdentifier', // scheme#code (ECL 2.1 alternate identifier)
91
105
 
92
106
  CONJUNCTION: 'Conjunction',
93
107
  DISJUNCTION: 'Disjunction',
@@ -213,6 +227,23 @@ class ECLLexer {
213
227
  continue;
214
228
  }
215
229
 
230
+ // ECL comments: /* ... */ are whitespace
231
+ if (this.current === '/' && this.peek() === '*') {
232
+ const start = this.position;
233
+ this.advance();
234
+ this.advance();
235
+ while (this.current && !(this.current === '*' && this.peek() === '/')) {
236
+ this.advance();
237
+ }
238
+ if (!this.current) {
239
+ this.position = start;
240
+ this.error('Unterminated comment - missing */');
241
+ }
242
+ this.advance();
243
+ this.advance();
244
+ continue;
245
+ }
246
+
216
247
  // Single character tokens
217
248
  switch (this.current) {
218
249
  case '(':
@@ -222,9 +253,21 @@ class ECLLexer {
222
253
  this.advance();
223
254
  return { type: ECLTokenType.RPAREN, value: ')' };
224
255
  case '{':
256
+ // "{{" opens an ECL 2.x filter constraint; attribute groups never nest,
257
+ // so a doubled brace is unambiguous.
258
+ if (this.peek() === '{') {
259
+ this.advance();
260
+ this.advance();
261
+ return { type: ECLTokenType.LFILTER, value: '{{' };
262
+ }
225
263
  this.advance();
226
264
  return { type: ECLTokenType.LBRACE, value: '{' };
227
265
  case '}':
266
+ if (this.peek() === '}') {
267
+ this.advance();
268
+ this.advance();
269
+ return { type: ECLTokenType.RFILTER, value: '}}' };
270
+ }
228
271
  this.advance();
229
272
  return { type: ECLTokenType.RBRACE, value: '}' };
230
273
  case '[':
@@ -332,7 +375,24 @@ class ECLLexer {
332
375
  return { type: ECLTokenType.EQUALS, value: '=' };
333
376
  }
334
377
 
378
+ if (this.current === '+') {
379
+ this.advance();
380
+ return { type: ECLTokenType.PLUS, value: '+' };
381
+ }
382
+
335
383
  if (this.current === '!') {
384
+ if (this.peek() === '!' && this.peek(2) === '>') {
385
+ this.advance();
386
+ this.advance();
387
+ this.advance();
388
+ return { type: ECLTokenType.TOP_OF_SET, value: '!!>' };
389
+ }
390
+ if (this.peek() === '!' && this.peek(2) === '<') {
391
+ this.advance();
392
+ this.advance();
393
+ this.advance();
394
+ return { type: ECLTokenType.BOTTOM_OF_SET, value: '!!<' };
395
+ }
336
396
  if (this.peek() === '=') {
337
397
  this.advance();
338
398
  this.advance();
@@ -379,7 +439,7 @@ class ECLLexer {
379
439
  // Keywords and identifiers
380
440
  if (/[a-zA-Z_]/.test(this.current)) {
381
441
  let value = '';
382
- while (this.current && /[a-zA-Z0-9_]/.test(this.current)) {
442
+ while (this.current && (/[a-zA-Z0-9_]/.test(this.current) || (this.current === '-' && /[a-zA-Z]/.test(this.peek() || '')))) {
383
443
  value += this.current;
384
444
  this.advance();
385
445
  }
@@ -550,7 +610,8 @@ class ECLParser {
550
610
  ECLTokenType.CHILD_OF, ECLTokenType.CHILD_OR_SELF_OF,
551
611
  ECLTokenType.DESCENDANT_OF, ECLTokenType.DESCENDANT_OR_SELF_OF,
552
612
  ECLTokenType.PARENT_OF, ECLTokenType.PARENT_OR_SELF_OF,
553
- ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF
613
+ ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF,
614
+ ECLTokenType.TOP_OF_SET, ECLTokenType.BOTTOM_OF_SET
554
615
  )) {
555
616
  operator = this.current;
556
617
  this.advance();
@@ -561,6 +622,22 @@ class ECLParser {
561
622
  // Handle memberOf
562
623
  if (this.match(ECLTokenType.MEMBER_OF)) {
563
624
  this.advance(); // consume ^
625
+ // Optional reference set field selection: ^ [field, field] refset
626
+ // (ECL 2.x). Parsed into focus.fields; the evaluator decides what it
627
+ // can do with it.
628
+ let fields = null;
629
+ if (this.match(ECLTokenType.LBRACKET)) {
630
+ this.advance();
631
+ fields = [];
632
+ do {
633
+ if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z][A-Za-z0-9_]*$/.test(this.current.value)) {
634
+ this.error('Expected a reference set field name after ^ [');
635
+ }
636
+ fields.push(this.current.value);
637
+ this.advance();
638
+ } while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
639
+ this.expect(ECLTokenType.RBRACKET);
640
+ }
564
641
  // Parse the reference set - can be concept reference, wildcard, or parenthesized expression
565
642
  // but NOT another constraint operator or member-of expression
566
643
  let refSet;
@@ -576,6 +653,9 @@ class ECLParser {
576
653
  type: ECLNodeType.MEMBER_OF,
577
654
  refSet
578
655
  };
656
+ if (fields) {
657
+ focus.fields = fields;
658
+ }
579
659
  } else if (this.match(ECLTokenType.LPAREN)) {
580
660
  this.advance(); // consume (
581
661
  focus = this.parseExpressionConstraint();
@@ -584,15 +664,512 @@ class ECLParser {
584
664
  focus = this.parseEclFocusConcept();
585
665
  }
586
666
 
667
+ // ECL 2.x filter constraints: *(memberFilterConstraint) directly after a
668
+ // memberOf focus, then *(descriptionFilter / conceptFilter) and an optional
669
+ // history supplement after any sub expression. Member filters are parsed
670
+ // into focus.memberFilters; the others are parsed into focus-level
671
+ // `filters` and rejected (as unsupported) at evaluation time.
672
+ const filters = [];
673
+ while (this.match(ECLTokenType.LFILTER)) {
674
+ const fc = this.parseFilterConstraint();
675
+ if (fc.kind === 'M') {
676
+ if (focus.type !== ECLNodeType.MEMBER_OF) {
677
+ throw new Error('A member filter ({{ M ... }}) may only follow a memberOf (^) expression constraint');
678
+ }
679
+ if (filters.length > 0) {
680
+ throw new Error('A member filter ({{ M ... }}) must precede any description, concept or history filter');
681
+ }
682
+ focus.memberFilters = (focus.memberFilters || []).concat(fc.filters);
683
+ } else {
684
+ filters.push(fc);
685
+ }
686
+ }
687
+
587
688
  const result = {
588
689
  type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT,
589
690
  operator: operator ? operator.type : null,
590
691
  focus
591
692
  };
693
+ if (filters.length > 0) {
694
+ result.filters = filters;
695
+ }
592
696
 
593
697
  return result;
594
698
  }
595
699
 
700
+ /**
701
+ * Parse one "{{ ... }}" block. Returns { kind: 'M'|'D'|'C'|'+', filters } where
702
+ * filters is only populated for member filters; the other kinds keep their
703
+ * raw token text so the evaluator can report them as unsupported.
704
+ */
705
+ parseFilterConstraint() {
706
+ this.expect(ECLTokenType.LFILTER);
707
+ let kind;
708
+ if (this.match(ECLTokenType.PLUS)) {
709
+ kind = '+';
710
+ this.advance();
711
+ } else if (this.match(ECLTokenType.SCTID) && /^[MDCmdc]$/.test(this.current.value)) {
712
+ kind = this.current.value.toUpperCase();
713
+ this.advance();
714
+ } else {
715
+ this.error('Expected M, D, C or + after {{');
716
+ }
717
+
718
+ if (kind === 'C' || kind === 'D') {
719
+ const parseOne = kind === 'C' ? () => this.parseConceptFilter() : () => this.parseDescriptionFilter();
720
+ const filters = [parseOne()];
721
+ while (this.match(ECLTokenType.COMMA)) {
722
+ this.advance();
723
+ filters.push(parseOne());
724
+ }
725
+ this.expect(ECLTokenType.RFILTER);
726
+ return { type: ECLNodeType.FILTER_CONSTRAINT, kind, filters };
727
+ }
728
+
729
+ if (kind !== 'M') {
730
+ // Skip to the closing }} keeping the raw text for diagnostics
731
+ const raw = [];
732
+ while (!this.match(ECLTokenType.RFILTER)) {
733
+ if (this.match(ECLTokenType.EOF)) {
734
+ this.error('Unterminated filter constraint - missing }}');
735
+ }
736
+ raw.push(this.current.value);
737
+ this.advance();
738
+ }
739
+ this.advance(); // }}
740
+ return { type: ECLNodeType.FILTER_CONSTRAINT, kind, raw: raw.join(' ') };
741
+ }
742
+
743
+ const filters = [this.parseMemberFilter()];
744
+ while (this.match(ECLTokenType.COMMA)) {
745
+ this.advance();
746
+ filters.push(this.parseMemberFilter());
747
+ }
748
+ this.expect(ECLTokenType.RFILTER);
749
+ return { type: ECLNodeType.FILTER_CONSTRAINT, kind, filters };
750
+ }
751
+
752
+ /**
753
+ * descriptionFilter = termFilter / languageFilter / typeFilter / dialectFilter
754
+ * / moduleFilter / effectiveTimeFilter / activeFilter / descriptionIdFilter
755
+ *
756
+ * term = / != (typedSearchTerm / "(" typedSearchTerm* ")") typedSearchTerm = [match:]"..." / wild:"..."
757
+ * language = / != (languageCode / "(" languageCode* ")") e.g. en, fr
758
+ * typeId = / != (subExpressionConstraint / "(" conceptRef* ")")
759
+ * type = / != (syn / fsn / def, or a bracketed set of them)
760
+ * dialectId = / != (subExpressionConstraint / "(" dialectId [acceptabilitySet]* ")") [acceptabilitySet]
761
+ * dialect = / != (dialectAlias / "(" dialectAlias [acceptabilitySet]* ")") [acceptabilitySet]
762
+ * moduleId = / != (subExpressionConstraint / "(" conceptRef* ")")
763
+ * effectiveTime timeComparisonOperator (timeValue / "(" timeValue* ")")
764
+ * active = / != (true / false / 1 / 0)
765
+ * id = / != (descriptionId / "(" descriptionId* ")")
766
+ *
767
+ * acceptabilitySet = "(" (preferred / acceptable / prefer / accept / conceptRef)* ")"
768
+ */
769
+ parseDescriptionFilter() {
770
+ if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z_]/.test(this.current.value)) {
771
+ this.error('Expected term, language, typeId, type, dialectId, dialect, moduleId, effectiveTime, active or id in description filter');
772
+ }
773
+ const field = this.current.value;
774
+ const lower = field.toLowerCase();
775
+ this.advance();
776
+ const operator = this.parseComparisonOperator(field);
777
+ const node = { type: ECLNodeType.DESCRIPTION_FILTER, field, operator };
778
+ const booleanOnly = () => {
779
+ if (operator !== '=' && operator !== '!=') {
780
+ throw new Error(`Operator ${operator} is not valid for ${field} in a description filter`);
781
+ }
782
+ };
783
+ switch (lower) {
784
+ case 'term':
785
+ booleanOnly();
786
+ node.valueType = 'term';
787
+ node.values = this.parseStringValueSet(true).map(v => typeof v === 'string' ? { kind: 'match', value: v } : v);
788
+ for (const v of node.values) {
789
+ if (v.value.trim() === '') {
790
+ throw new Error('A term filter needs a non-empty search term');
791
+ }
792
+ }
793
+ break;
794
+ case 'language':
795
+ booleanOnly();
796
+ node.valueType = 'language';
797
+ node.values = this.parseTokenSet(/^[a-z]{2}$/i, 'a two-letter language code').map(v => v.toLowerCase());
798
+ break;
799
+ case 'typeid':
800
+ case 'moduleid':
801
+ booleanOnly();
802
+ node.valueType = 'expression';
803
+ node.value = this.parseConceptReferenceSetOrExpression();
804
+ break;
805
+ case 'type':
806
+ booleanOnly();
807
+ node.valueType = 'type';
808
+ node.values = this.parseTokenSet(/^(syn|fsn|def)$/i, 'syn, fsn or def').map(v => v.toLowerCase());
809
+ break;
810
+ case 'dialectid':
811
+ case 'dialect': {
812
+ booleanOnly();
813
+ node.valueType = lower === 'dialectid' ? 'dialectId' : 'dialect';
814
+ node.dialects = this.parseDialectSet(lower === 'dialectid');
815
+ if (this.match(ECLTokenType.LPAREN)) {
816
+ const acc = this.parseAcceptabilitySet();
817
+ for (const d of node.dialects) {
818
+ if (!d.acceptability) d.acceptability = acc;
819
+ }
820
+ }
821
+ break;
822
+ }
823
+ case 'effectivetime':
824
+ node.valueType = 'time';
825
+ node.values = this.parseTimeValueSet();
826
+ break;
827
+ case 'active':
828
+ booleanOnly();
829
+ node.valueType = 'boolean';
830
+ node.value = this.parseBooleanValue();
831
+ break;
832
+ case 'id':
833
+ booleanOnly();
834
+ node.valueType = 'id';
835
+ node.values = this.parseTokenSet(/^\d{6,18}$/, 'a description id');
836
+ break;
837
+ default:
838
+ throw new Error(`Unknown description filter field ${field} - expected term, language, typeId, type, dialectId, dialect, moduleId, effectiveTime, active or id`);
839
+ }
840
+ return node;
841
+ }
842
+
843
+ // token / "(" token* ")" where each token is an identifier matching `pattern`
844
+ parseTokenSet(pattern, what) {
845
+ const values = [];
846
+ const readOne = () => {
847
+ if (this.match(ECLTokenType.SCTID) && pattern.test(this.current.value)) {
848
+ values.push(this.current.value);
849
+ this.advance();
850
+ } else {
851
+ this.error(`Expected ${what}`);
852
+ }
853
+ };
854
+ if (this.match(ECLTokenType.LPAREN)) {
855
+ this.advance();
856
+ do {
857
+ readOne();
858
+ } while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF));
859
+ this.expect(ECLTokenType.RPAREN);
860
+ } else {
861
+ readOne();
862
+ }
863
+ return values;
864
+ }
865
+
866
+ /**
867
+ * The dialects of a dialect filter: [{ id | alias, acceptability? }]. A
868
+ * dialectId value may be a sub expression (one entry with `expression`), or a
869
+ * bracketed list of ids each optionally followed by its own acceptability set;
870
+ * a dialect alias (en-us, en-gb, ...) is an identifier.
871
+ */
872
+ parseDialectSet(byId) {
873
+ const readAlias = () => {
874
+ // "en-gb" lexes as SCTID(en-gb) because '-' followed by a letter is kept in identifiers
875
+ if (this.match(ECLTokenType.SCTID) && /^[a-z]{2}(-[a-z0-9]+)*$/i.test(this.current.value)) {
876
+ const alias = this.current.value.toLowerCase();
877
+ this.advance();
878
+ return { alias };
879
+ }
880
+ this.error('Expected a dialect alias such as en-us or en-gb');
881
+ };
882
+ if (!byId) {
883
+ if (!this.match(ECLTokenType.LPAREN)) {
884
+ return [readAlias()];
885
+ }
886
+ this.advance();
887
+ const dialects = [];
888
+ while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF)) {
889
+ const d = readAlias();
890
+ if (this.match(ECLTokenType.LPAREN)) d.acceptability = this.parseAcceptabilitySet();
891
+ dialects.push(d);
892
+ }
893
+ this.expect(ECLTokenType.RPAREN);
894
+ return dialects;
895
+ }
896
+ // by id: "(" id [acceptabilitySet] ... ")" or a sub expression
897
+ if (this.match(ECLTokenType.LPAREN) && this.peek().type === ECLTokenType.SCTID && /^\d+$/.test(this.peek().value)) {
898
+ this.advance();
899
+ const dialects = [];
900
+ while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF)) {
901
+ const d = { expression: { type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT, operator: null, focus: this.parseEclFocusConcept() } };
902
+ if (this.match(ECLTokenType.LPAREN)) d.acceptability = this.parseAcceptabilitySet();
903
+ dialects.push(d);
904
+ }
905
+ this.expect(ECLTokenType.RPAREN);
906
+ return dialects;
907
+ }
908
+ return [{ expression: this.parseSubExpressionConstraint() }];
909
+ }
910
+
911
+ // "(" (preferred / acceptable / prefer / accept / conceptRef)* ")" -> ['preferred'|'acceptable'|conceptId ...]
912
+ parseAcceptabilitySet() {
913
+ this.expect(ECLTokenType.LPAREN);
914
+ const values = [];
915
+ while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF)) {
916
+ if (this.match(ECLTokenType.SCTID) && /^(prefer|preferred|accept|acceptable)$/i.test(this.current.value)) {
917
+ values.push(/^pref/i.test(this.current.value) ? 'preferred' : 'acceptable');
918
+ this.advance();
919
+ } else if (this.match(ECLTokenType.SCTID) && /^\d+$/.test(this.current.value)) {
920
+ values.push(this.parseEclFocusConcept().conceptId);
921
+ } else {
922
+ this.error('Expected preferred, acceptable or an acceptability concept id');
923
+ }
924
+ }
925
+ this.expect(ECLTokenType.RPAREN);
926
+ if (values.length === 0) {
927
+ this.error('An acceptability set must not be empty');
928
+ }
929
+ return values;
930
+ }
931
+
932
+ /**
933
+ * conceptFilter = definitionStatusIdFilter / definitionStatusTokenFilter / moduleFilter
934
+ * / effectiveTimeFilter / activeFilter
935
+ *
936
+ * definitionStatusId = / != (subExpressionConstraint / "(" conceptRef* ")")
937
+ * definitionStatus = / != (primitive / defined / "(" (primitive / defined)* ")")
938
+ * moduleId = / != (subExpressionConstraint / "(" conceptRef* ")")
939
+ * effectiveTime timeComparisonOperator (timeValue / "(" timeValue* ")")
940
+ * active = / != (true / false / 1 / 0)
941
+ */
942
+ parseConceptFilter() {
943
+ if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z_]/.test(this.current.value)) {
944
+ this.error('Expected definitionStatusId, definitionStatus, moduleId, effectiveTime or active in concept filter');
945
+ }
946
+ const field = this.current.value;
947
+ const lower = field.toLowerCase();
948
+ this.advance();
949
+ const operator = this.parseComparisonOperator(field);
950
+ const node = { type: ECLNodeType.CONCEPT_FILTER, field, operator };
951
+ const booleanOnly = () => {
952
+ if (operator !== '=' && operator !== '!=') {
953
+ throw new Error(`Operator ${operator} is not valid for ${field} in a concept filter`);
954
+ }
955
+ };
956
+ switch (lower) {
957
+ case 'effectivetime':
958
+ node.valueType = 'time';
959
+ node.values = this.parseTimeValueSet();
960
+ break;
961
+ case 'active':
962
+ booleanOnly();
963
+ node.valueType = 'boolean';
964
+ node.value = this.parseBooleanValue();
965
+ break;
966
+ case 'moduleid':
967
+ case 'definitionstatusid':
968
+ booleanOnly();
969
+ node.valueType = 'expression';
970
+ node.value = this.parseConceptReferenceSetOrExpression();
971
+ break;
972
+ case 'definitionstatus': {
973
+ booleanOnly();
974
+ node.valueType = 'definitionStatus';
975
+ node.values = [];
976
+ const readToken = () => {
977
+ if (this.match(ECLTokenType.SCTID) && /^(primitive|defined)$/i.test(this.current.value)) {
978
+ node.values.push(this.current.value.toLowerCase());
979
+ this.advance();
980
+ } else {
981
+ this.error('Expected primitive or defined');
982
+ }
983
+ };
984
+ if (this.match(ECLTokenType.LPAREN)) {
985
+ this.advance();
986
+ do {
987
+ readToken();
988
+ } while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF));
989
+ this.expect(ECLTokenType.RPAREN);
990
+ } else {
991
+ readToken();
992
+ }
993
+ break;
994
+ }
995
+ default:
996
+ throw new Error(`Unknown concept filter field ${field} - expected definitionStatusId, definitionStatus, moduleId, effectiveTime or active`);
997
+ }
998
+ return node;
999
+ }
1000
+
1001
+ parseComparisonOperator(field) {
1002
+ if (this.match(ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS, ECLTokenType.LT, ECLTokenType.LTE,
1003
+ ECLTokenType.GT, ECLTokenType.GTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF)) {
1004
+ // "<" and ">" lex as hierarchy operators; in a comparison they are less/greater than
1005
+ const t = this.current.type;
1006
+ const operator = t === ECLTokenType.DESCENDANT_OF ? '<' : t === ECLTokenType.ANCESTOR_OF ? '>' : this.current.value;
1007
+ this.advance();
1008
+ return operator;
1009
+ }
1010
+ this.error(`Expected comparison operator after ${field}`);
1011
+ }
1012
+
1013
+ parseTimeValueSet() {
1014
+ const values = this.parseStringValueSet();
1015
+ for (const v of values) {
1016
+ if (!/^\d{8}$/.test(v)) {
1017
+ throw new Error(`Invalid effectiveTime value "${v}" - expected "YYYYMMDD"`);
1018
+ }
1019
+ }
1020
+ return values;
1021
+ }
1022
+
1023
+ /**
1024
+ * subExpressionConstraint / eclConceptReferenceSet. The reference set form
1025
+ * "( 123 |a| 456 |b| )" is a bare list of concept references, which is not a
1026
+ * valid expression; it is parsed into an OR of the references.
1027
+ */
1028
+ parseConceptReferenceSetOrExpression() {
1029
+ if (this.match(ECLTokenType.LPAREN)) {
1030
+ // look ahead: "(" SCTID [TERM] (SCTID / ")") marks a reference set
1031
+ let i = 1;
1032
+ if (this.peek(i).type === ECLTokenType.SCTID && /^\d+$/.test(this.peek(i).value)) {
1033
+ i++;
1034
+ if (this.peek(i).type === ECLTokenType.TERM) i++;
1035
+ if (this.peek(i).type === ECLTokenType.SCTID || this.peek(i).type === ECLTokenType.RPAREN) {
1036
+ this.advance(); // (
1037
+ let node = null;
1038
+ while (!this.match(ECLTokenType.RPAREN)) {
1039
+ if (!this.match(ECLTokenType.SCTID)) {
1040
+ this.error('Expected a concept reference in the reference set');
1041
+ }
1042
+ const ref = { type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT, operator: null, focus: this.parseEclFocusConcept() };
1043
+ node = node === null ? ref : {
1044
+ type: ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT, operator: ECLNodeType.DISJUNCTION, left: node, right: ref
1045
+ };
1046
+ }
1047
+ this.advance(); // )
1048
+ return node;
1049
+ }
1050
+ }
1051
+ }
1052
+ return this.parseSubExpressionConstraint();
1053
+ }
1054
+
1055
+ /**
1056
+ * memberFilter = (refsetFieldName / "moduleId" / "effectiveTime" / "active") ws comparison
1057
+ *
1058
+ * The comparison form is decided by the field name for the three standard
1059
+ * fields, and otherwise by the token that follows the operator:
1060
+ * "#" number -> numeric
1061
+ * string / ( string ... ) -> string (or time, for an 8-digit value)
1062
+ * true/false/1/0 -> boolean
1063
+ * anything else -> subExpressionConstraint (a concept-valued field)
1064
+ */
1065
+ parseMemberFilter() {
1066
+ if (!this.match(ECLTokenType.SCTID) || !/^[A-Za-z_]/.test(this.current.value)) {
1067
+ this.error('Expected a reference set field name in member filter');
1068
+ }
1069
+ const field = this.current.value;
1070
+ this.advance();
1071
+
1072
+ const operator = this.parseComparisonOperator(field);
1073
+ const node = { type: ECLNodeType.MEMBER_FILTER, field, operator };
1074
+ const lower = field.toLowerCase();
1075
+
1076
+ if (lower === 'effectivetime') {
1077
+ node.valueType = 'time';
1078
+ node.values = this.parseTimeValueSet();
1079
+ } else if (lower === 'active') {
1080
+ node.valueType = 'boolean';
1081
+ node.value = this.parseBooleanValue();
1082
+ if (operator !== '=' && operator !== '!=') {
1083
+ throw new Error(`Operator ${operator} is not valid for a boolean member filter`);
1084
+ }
1085
+ } else if (lower === 'moduleid') {
1086
+ node.valueType = 'expression';
1087
+ node.value = this.parseConceptReferenceSetOrExpression();
1088
+ if (operator !== '=' && operator !== '!=') {
1089
+ throw new Error(`Operator ${operator} is not valid for moduleId in a member filter`);
1090
+ }
1091
+ } else if (this.match(ECLTokenType.HASH)) {
1092
+ this.advance();
1093
+ if (!this.match(ECLTokenType.INTEGER, ECLTokenType.DECIMAL, ECLTokenType.SCTID)) {
1094
+ this.error('Expected a number after #');
1095
+ }
1096
+ node.valueType = 'numeric';
1097
+ node.value = this.current.value;
1098
+ this.advance();
1099
+ } else if (this.match(ECLTokenType.STRING, ECLTokenType.LPAREN) && this.#looksLikeStringSet()) {
1100
+ node.valueType = 'string';
1101
+ node.values = this.parseStringValueSet();
1102
+ } else if (this.match(ECLTokenType.SCTID) && /^(true|false|1|0)$/i.test(this.current.value) &&
1103
+ !this.#followedByTerm()) {
1104
+ node.valueType = 'boolean';
1105
+ node.value = this.parseBooleanValue();
1106
+ } else {
1107
+ node.valueType = 'expression';
1108
+ node.value = this.parseSubExpressionConstraint();
1109
+ if (operator !== '=' && operator !== '!=') {
1110
+ throw new Error(`Operator ${operator} is not valid for a concept-valued member filter field`);
1111
+ }
1112
+ }
1113
+ return node;
1114
+ }
1115
+
1116
+ // Is the current token a string, or a bracketed list of strings (as opposed to
1117
+ // a bracketed sub expression constraint)?
1118
+ #looksLikeStringSet() {
1119
+ if (this.match(ECLTokenType.STRING)) return true;
1120
+ return this.peek().type === ECLTokenType.STRING;
1121
+ }
1122
+
1123
+ #followedByTerm() {
1124
+ return this.peek().type === ECLTokenType.TERM;
1125
+ }
1126
+
1127
+ // string / "(" string *(ws string) ")" (also accepts the typed forms match:"x" / wild:"x")
1128
+ parseStringValueSet(allowWild = false) {
1129
+ const values = [];
1130
+ const readOne = () => {
1131
+ if (this.match(ECLTokenType.SCTID) && /^(match|wild)$/i.test(this.current.value) &&
1132
+ this.peek().type === ECLTokenType.COLON) {
1133
+ const kind = this.current.value.toLowerCase();
1134
+ this.advance();
1135
+ this.advance();
1136
+ if (kind === 'wild' && !allowWild) {
1137
+ throw new Error('wild: search terms are not supported in member filters');
1138
+ }
1139
+ if (!this.match(ECLTokenType.STRING)) {
1140
+ this.error('Expected a quoted string value');
1141
+ }
1142
+ values.push({ kind, value: this.current.value });
1143
+ this.advance();
1144
+ return;
1145
+ }
1146
+ if (!this.match(ECLTokenType.STRING)) {
1147
+ this.error('Expected a quoted string value');
1148
+ }
1149
+ values.push(this.current.value);
1150
+ this.advance();
1151
+ };
1152
+ if (this.match(ECLTokenType.LPAREN)) {
1153
+ this.advance();
1154
+ do {
1155
+ readOne();
1156
+ } while (!this.match(ECLTokenType.RPAREN) && !this.match(ECLTokenType.EOF));
1157
+ this.expect(ECLTokenType.RPAREN);
1158
+ } else {
1159
+ readOne();
1160
+ }
1161
+ return values;
1162
+ }
1163
+
1164
+ parseBooleanValue() {
1165
+ if (this.match(ECLTokenType.SCTID) && /^(true|false|1|0)$/i.test(this.current.value)) {
1166
+ const v = this.current.value.toLowerCase();
1167
+ this.advance();
1168
+ return v === 'true' || v === '1';
1169
+ }
1170
+ this.error('Expected true, false, 1 or 0');
1171
+ }
1172
+
596
1173
  parseEclFocusConcept() {
597
1174
  if (this.match(ECLTokenType.WILDCARD)) {
598
1175
  this.advance();
@@ -601,8 +1178,33 @@ class ECLParser {
601
1178
  };
602
1179
  }
603
1180
 
1181
+ // ECL 2.1 alternate identifiers: "scheme#code" (quoted) or scheme#code
1182
+ if (this.match(ECLTokenType.STRING) && /^[A-Za-z0-9_-]+#.+$/.test(this.current.value)) {
1183
+ const [scheme, ...rest] = this.current.value.split('#');
1184
+ this.advance();
1185
+ return this.#altIdentifier(scheme, rest.join('#'));
1186
+ }
1187
+ if (this.match(ECLTokenType.SCTID) && /^[A-Za-z]/.test(this.current.value) && this.peek().type === ECLTokenType.HASH) {
1188
+ const scheme = this.current.value;
1189
+ this.advance();
1190
+ this.advance(); // #
1191
+ let code = '';
1192
+ // the code runs until whitespace: alphanumerics, "-", "." and "_"
1193
+ while (this.match(ECLTokenType.SCTID, ECLTokenType.INTEGER, ECLTokenType.DECIMAL, ECLTokenType.DOT, ECLTokenType.CARDINALITY_RANGE)) {
1194
+ code += this.current.value;
1195
+ this.advance();
1196
+ }
1197
+ if (code === '') {
1198
+ this.error(`Expected a code after ${scheme}#`);
1199
+ }
1200
+ return this.#altIdentifier(scheme, code);
1201
+ }
1202
+
604
1203
  if (this.match(ECLTokenType.SCTID)) {
605
1204
  const conceptId = this.current.value;
1205
+ if (!/^\d{6,18}$/.test(conceptId)) {
1206
+ this.error(`"${conceptId}" is not a valid SNOMED CT concept id`);
1207
+ }
606
1208
  this.advance();
607
1209
 
608
1210
  let term = null;
@@ -621,60 +1223,130 @@ class ECLParser {
621
1223
  this.error('Expected concept reference or wildcard');
622
1224
  }
623
1225
 
624
- parseRefinement() {
625
- return this.parseAttributeSet();
1226
+ #altIdentifier(scheme, code) {
1227
+ let term = null;
1228
+ if (this.match(ECLTokenType.TERM)) {
1229
+ term = this.current.value;
1230
+ this.advance();
1231
+ }
1232
+ return { type: ECLNodeType.ALT_IDENTIFIER, scheme, code, term };
626
1233
  }
627
1234
 
628
- parseAttributeSet() {
629
- const attributes = [];
1235
+ /**
1236
+ * eclRefinement = subRefinement *(ws (AND / OR / ",") ws subRefinement)
1237
+ * subRefinement = eclAttributeSet / eclAttributeGroup / "(" eclRefinement ")"
1238
+ *
1239
+ * "," is a conjunction. As with compound expressions, AND and OR may not be
1240
+ * mixed in one un-bracketed sequence (ECL 6.4). A single sub refinement is
1241
+ * returned as-is; otherwise an ATTRIBUTE_SET with operator 'AND' or 'OR'.
1242
+ */
1243
+ parseRefinement() {
1244
+ return this.parseRefinementSet(() => this.parseSubRefinement(), 'refinement');
1245
+ }
630
1246
 
631
- do {
632
- // A leading cardinality may prefix either a single attribute
633
- // ([m..n] attr = v) or an attribute group ([m..n] { ... }). Parse it once,
634
- // then dispatch on whether a group ({) or an attribute follows.
635
- let cardinality = null;
636
- if (this.match(ECLTokenType.LBRACKET)) {
637
- cardinality = this.parseCardinality();
638
- }
639
- if (this.match(ECLTokenType.LBRACE)) {
640
- // Attribute group
641
- attributes.push(this.parseAttributeGroup(cardinality));
1247
+ parseRefinementSet(parseItem, what) {
1248
+ const items = [parseItem()];
1249
+ let operator = null;
1250
+ for (;;) {
1251
+ let op;
1252
+ if (this.match(ECLTokenType.COMMA, ECLTokenType.AND)) {
1253
+ op = 'AND';
1254
+ } else if (this.match(ECLTokenType.OR)) {
1255
+ op = 'OR';
642
1256
  } else {
643
- // Single attribute
644
- attributes.push(this.parseAttribute(cardinality));
1257
+ break;
645
1258
  }
646
- } while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
647
-
648
- if (attributes.length === 1) {
649
- return attributes[0];
1259
+ if (operator !== null && op !== operator) {
1260
+ throw new Error(
1261
+ `Ambiguous ECL ${what}: AND (or ",") and OR cannot be mixed without parentheses (ECL 6.4). ` +
1262
+ 'Use round brackets to disambiguate.');
1263
+ }
1264
+ operator = op;
1265
+ this.advance();
1266
+ items.push(parseItem());
1267
+ }
1268
+ if (items.length === 1) {
1269
+ return items[0];
650
1270
  }
651
-
652
1271
  return {
653
1272
  type: ECLNodeType.ATTRIBUTE_SET,
654
- attributes
1273
+ operator,
1274
+ attributes: items
655
1275
  };
656
1276
  }
657
1277
 
1278
+ parseSubRefinement() {
1279
+ // A leading cardinality may prefix either a single attribute
1280
+ // ([m..n] attr = v) or an attribute group ([m..n] { ... }). Parse it once,
1281
+ // then dispatch on whether a group ({) or an attribute follows.
1282
+ let cardinality = null;
1283
+ if (this.match(ECLTokenType.LBRACKET)) {
1284
+ cardinality = this.parseCardinality();
1285
+ }
1286
+ if (this.match(ECLTokenType.LBRACE)) {
1287
+ return this.parseAttributeGroup(cardinality);
1288
+ }
1289
+ if (cardinality === null && this.match(ECLTokenType.LPAREN) && !this.#parenIsAttributeName()) {
1290
+ this.advance();
1291
+ const inner = this.parseRefinement();
1292
+ this.expect(ECLTokenType.RPAREN);
1293
+ return inner;
1294
+ }
1295
+ return this.parseAttribute(cardinality);
1296
+ }
1297
+
1298
+ // At a "(" inside a refinement: is this a bracketed attribute name
1299
+ // ("(A OR B) = x") rather than a bracketed sub refinement ("(A = x OR B = y)")?
1300
+ // It is when the token after the matching ")" is a comparison operator.
1301
+ #parenIsAttributeName() {
1302
+ let depth = 0;
1303
+ for (let i = 0; ; i++) {
1304
+ const t = this.peek(i).type;
1305
+ if (t === ECLTokenType.EOF) return false;
1306
+ if (t === ECLTokenType.LPAREN) depth++;
1307
+ if (t === ECLTokenType.RPAREN && --depth === 0) {
1308
+ return [ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS, ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.GT,
1309
+ ECLTokenType.GTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF].includes(this.peek(i + 1).type);
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * eclAttributeSet = subAttributeSet *(ws (AND / OR / ",") ws subAttributeSet)
1316
+ * subAttributeSet = eclAttribute / "(" eclAttributeSet ")"
1317
+ * (the contents of an attribute group - no nested groups)
1318
+ */
1319
+ parseAttributeSet() {
1320
+ return this.parseRefinementSet(() => {
1321
+ if (this.match(ECLTokenType.LPAREN) && !this.#parenIsAttributeName()) {
1322
+ this.advance();
1323
+ const inner = this.parseAttributeSet();
1324
+ this.expect(ECLTokenType.RPAREN);
1325
+ return inner;
1326
+ }
1327
+ return this.parseAttribute();
1328
+ }, 'attribute set');
1329
+ }
1330
+
658
1331
  parseAttributeGroup(cardinality = null) {
659
- // Cardinality may have been parsed by the caller (parseAttributeSet); if
1332
+ // Cardinality may have been parsed by the caller (parseSubRefinement); if
660
1333
  // not, check for it here (before the opening brace).
661
1334
  if (cardinality === null && this.match(ECLTokenType.LBRACKET)) {
662
1335
  cardinality = this.parseCardinality();
663
1336
  }
664
1337
 
665
1338
  this.expect(ECLTokenType.LBRACE);
666
-
667
- const attributes = [];
668
- do {
669
- attributes.push(this.parseAttribute());
670
- } while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
671
-
1339
+ const content = this.parseAttributeSet();
672
1340
  this.expect(ECLTokenType.RBRACE);
673
1341
 
674
1342
  return {
675
1343
  type: ECLNodeType.ATTRIBUTE_GROUP,
676
1344
  cardinality,
677
- attributes
1345
+ // A plain conjunction of attributes ("{ a = x, b = y }") stays a flat
1346
+ // list, as it always was; anything with OR or nesting is a single
1347
+ // ATTRIBUTE_SET entry carrying that structure.
1348
+ attributes: content.type === ECLNodeType.ATTRIBUTE_SET && content.operator === 'AND' &&
1349
+ content.attributes.every(a => a.type === ECLNodeType.ATTRIBUTE) ? content.attributes : [content]
678
1350
  };
679
1351
  }
680
1352
 
@@ -700,13 +1372,30 @@ class ECLParser {
700
1372
  if (this.match(ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS)) {
701
1373
  const operator = this.current;
702
1374
  this.advance();
703
- const value = this.parseSubExpressionConstraint();
704
-
705
- comparison = {
706
- type: ECLNodeType.EXPRESSION_COMPARISON,
707
- operator: operator.type,
708
- value
709
- };
1375
+ if (this.match(ECLTokenType.HASH)) {
1376
+ // numeric concrete value: = #5, != #1.5
1377
+ this.advance();
1378
+ if (!this.match(ECLTokenType.SCTID, ECLTokenType.DECIMAL, ECLTokenType.INTEGER)) {
1379
+ this.error('Expected numeric value after #');
1380
+ }
1381
+ comparison = { type: ECLNodeType.NUMERIC_COMPARISON, operator: operator.type, value: this.current.value };
1382
+ this.advance();
1383
+ } else if (this.match(ECLTokenType.STRING) ||
1384
+ (this.match(ECLTokenType.LPAREN) && this.peek().type === ECLTokenType.STRING) ||
1385
+ (this.match(ECLTokenType.SCTID) && /^(match|wild)$/i.test(this.current.value) && this.peek().type === ECLTokenType.COLON)) {
1386
+ // string concrete value: = "x", != ("x" "y"), = match:"x"
1387
+ comparison = { type: ECLNodeType.STRING_COMPARISON, operator: operator.type, values: this.parseStringValueSet(true) };
1388
+ } else if (this.match(ECLTokenType.SCTID) && /^(true|false)$/i.test(this.current.value) && this.peek().type !== ECLTokenType.TERM) {
1389
+ // boolean concrete value: = true, != false
1390
+ comparison = { type: ECLNodeType.BOOLEAN_COMPARISON, operator: operator.type, value: this.parseBooleanValue() };
1391
+ } else {
1392
+ const value = this.parseSubExpressionConstraint();
1393
+ comparison = {
1394
+ type: ECLNodeType.EXPRESSION_COMPARISON,
1395
+ operator: operator.type,
1396
+ value
1397
+ };
1398
+ }
710
1399
  } else if (this.match(ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.DESCENDANT_OF, ECLTokenType.ANCESTOR_OF, ECLTokenType.GTE)) {
711
1400
  // In ECL, bare `<` and `>` are overloaded: they lex as hierarchy
712
1401
  // operators (DESCENDANT_OF / ANCESTOR_OF) but in an attribute comparison
@@ -750,8 +1439,18 @@ class ECLParser {
750
1439
  };
751
1440
  }
752
1441
 
1442
+ /**
1443
+ * eclAttributeName = subExpressionConstraint. A plain concept reference or
1444
+ * wildcard is returned unwrapped (the shape every consumer expects); anything
1445
+ * else (<< 363698007, (A OR B), ^ refset) is returned as the sub expression.
1446
+ */
753
1447
  parseAttributeName() {
754
- return this.parseEclFocusConcept();
1448
+ const node = this.parseSubExpressionConstraint();
1449
+ if (node.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT && !node.operator && !node.filters &&
1450
+ (node.focus.type === ECLNodeType.CONCEPT_REFERENCE || node.focus.type === ECLNodeType.WILDCARD)) {
1451
+ return node.focus;
1452
+ }
1453
+ return node;
755
1454
  }
756
1455
 
757
1456
  parseCardinality() {
@@ -865,11 +1564,23 @@ class ECLValidator {
865
1564
 
866
1565
  case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
867
1566
  this.validateAST(node.focus);
1567
+ for (const fc of node.filters || []) {
1568
+ for (const f of fc.filters || []) {
1569
+ if (f.valueType === 'expression') {
1570
+ this.validateAST(f.value);
1571
+ }
1572
+ }
1573
+ }
868
1574
  break;
869
1575
 
870
1576
  case ECLNodeType.MEMBER_OF:
871
1577
  this.validateAST(node.refSet);
872
1578
  this.validateReferenceSet(node.refSet);
1579
+ for (const f of node.memberFilters || []) {
1580
+ if (f.valueType === 'expression') {
1581
+ this.validateAST(f.value);
1582
+ }
1583
+ }
873
1584
  break;
874
1585
 
875
1586
  case ECLNodeType.ATTRIBUTE_SET:
@@ -1002,7 +1713,8 @@ class ECLValidator {
1002
1713
  break;
1003
1714
 
1004
1715
  case ECLNodeType.STRING_COMPARISON:
1005
- // String values are always valid
1716
+ case ECLNodeType.BOOLEAN_COMPARISON:
1717
+ // String and boolean values are always valid
1006
1718
  break;
1007
1719
  }
1008
1720
  }
@@ -1606,10 +2318,10 @@ class ECLValidator {
1606
2318
  attributes.push(refinementNode);
1607
2319
  break;
1608
2320
  case ECLNodeType.ATTRIBUTE_SET:
1609
- attributes.push(...refinementNode.attributes);
1610
- break;
1611
2321
  case ECLNodeType.ATTRIBUTE_GROUP:
1612
- attributes.push(...refinementNode.attributes);
2322
+ for (const a of refinementNode.attributes) {
2323
+ attributes.push(...this.extractAttributesFromRefinement(a));
2324
+ }
1613
2325
  break;
1614
2326
  }
1615
2327