fhirsmith 0.3.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.
- package/CHANGELOG.md +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- package/xig/xig.js +3049 -0
package/tx/sct/ecl.js
ADDED
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SNOMED CT Expression Constraint Language (ECL) Validator
|
|
3
|
+
*
|
|
4
|
+
* This implementation provides parsing, validation, and evaluation of ECL expressions
|
|
5
|
+
* against SNOMED CT data using the existing SnomedServices infrastructure.
|
|
6
|
+
*
|
|
7
|
+
* Supports ECL v2.1 specification from SNOMED International
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { SnomedFilterContext } = require('../cs/cs-snomed');
|
|
11
|
+
|
|
12
|
+
// ECL Token Types
|
|
13
|
+
const ECLTokenType = {
|
|
14
|
+
// Literals
|
|
15
|
+
SCTID: 'SCTID',
|
|
16
|
+
TERM: 'TERM',
|
|
17
|
+
STRING: 'STRING',
|
|
18
|
+
INTEGER: 'INTEGER',
|
|
19
|
+
DECIMAL: 'DECIMAL',
|
|
20
|
+
|
|
21
|
+
// Operators
|
|
22
|
+
CHILD_OF: 'CHILD_OF', // <
|
|
23
|
+
CHILD_OR_SELF_OF: 'CHILD_OR_SELF_OF', // <<
|
|
24
|
+
DESCENDANT_OF: 'DESCENDANT_OF', // <!
|
|
25
|
+
DESCENDANT_OR_SELF_OF: 'DESCENDANT_OR_SELF_OF', // <<!
|
|
26
|
+
PARENT_OF: 'PARENT_OF', // >
|
|
27
|
+
PARENT_OR_SELF_OF: 'PARENT_OR_SELF_OF', // >>
|
|
28
|
+
ANCESTOR_OF: 'ANCESTOR_OF', // >!
|
|
29
|
+
ANCESTOR_OR_SELF_OF: 'ANCESTOR_OR_SELF_OF', // >>!
|
|
30
|
+
|
|
31
|
+
// Set operators
|
|
32
|
+
AND: 'AND',
|
|
33
|
+
OR: 'OR',
|
|
34
|
+
MINUS: 'MINUS',
|
|
35
|
+
|
|
36
|
+
// Refinement
|
|
37
|
+
COLON: 'COLON', // :
|
|
38
|
+
EQUALS: 'EQUALS', // =
|
|
39
|
+
NOT_EQUALS: 'NOT_EQUALS', // !=
|
|
40
|
+
|
|
41
|
+
// Comparison operators
|
|
42
|
+
LT: 'LT', // <
|
|
43
|
+
LTE: 'LTE', // <=
|
|
44
|
+
GT: 'GT', // >
|
|
45
|
+
GTE: 'GTE', // >=
|
|
46
|
+
|
|
47
|
+
// Special
|
|
48
|
+
MEMBER_OF: 'MEMBER_OF', // ^
|
|
49
|
+
WILDCARD: 'WILDCARD', // *
|
|
50
|
+
DOT: 'DOT', // .
|
|
51
|
+
CARDINALITY_RANGE: 'CARDINALITY_RANGE', // ..
|
|
52
|
+
REVERSE: 'REVERSE', // R
|
|
53
|
+
|
|
54
|
+
// Brackets
|
|
55
|
+
LPAREN: 'LPAREN', // (
|
|
56
|
+
RPAREN: 'RPAREN', // )
|
|
57
|
+
LBRACE: 'LBRACE', // {
|
|
58
|
+
RBRACE: 'RBRACE', // }
|
|
59
|
+
LBRACKET: 'LBRACKET', // [
|
|
60
|
+
RBRACKET: 'RBRACKET', // ]
|
|
61
|
+
|
|
62
|
+
// Separators
|
|
63
|
+
COMMA: 'COMMA', // ,
|
|
64
|
+
PIPE: 'PIPE', // |
|
|
65
|
+
HASH: 'HASH', // #
|
|
66
|
+
|
|
67
|
+
// Special tokens
|
|
68
|
+
WS: 'WS',
|
|
69
|
+
EOF: 'EOF',
|
|
70
|
+
ERROR: 'ERROR'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ECL Expression Node Types
|
|
74
|
+
const ECLNodeType = {
|
|
75
|
+
SIMPLE_EXPRESSION_CONSTRAINT: 'SimpleExpressionConstraint',
|
|
76
|
+
REFINED_EXPRESSION_CONSTRAINT: 'RefinedExpressionConstraint',
|
|
77
|
+
COMPOUND_EXPRESSION_CONSTRAINT: 'CompoundExpressionConstraint',
|
|
78
|
+
DOTTED_EXPRESSION_CONSTRAINT: 'DottedExpressionConstraint',
|
|
79
|
+
|
|
80
|
+
CONCEPT_REFERENCE: 'ConceptReference',
|
|
81
|
+
WILDCARD: 'Wildcard',
|
|
82
|
+
MEMBER_OF: 'MemberOf',
|
|
83
|
+
|
|
84
|
+
REFINEMENT: 'Refinement',
|
|
85
|
+
ATTRIBUTE_SET: 'AttributeSet',
|
|
86
|
+
ATTRIBUTE_GROUP: 'AttributeGroup',
|
|
87
|
+
ATTRIBUTE: 'Attribute',
|
|
88
|
+
|
|
89
|
+
SUB_EXPRESSION_CONSTRAINT: 'SubExpressionConstraint',
|
|
90
|
+
EXPRESSION_COMPARISON: 'ExpressionComparison',
|
|
91
|
+
NUMERIC_COMPARISON: 'NumericComparison',
|
|
92
|
+
STRING_COMPARISON: 'StringComparison',
|
|
93
|
+
|
|
94
|
+
CONJUNCTION: 'Conjunction',
|
|
95
|
+
DISJUNCTION: 'Disjunction',
|
|
96
|
+
EXCLUSION: 'Exclusion',
|
|
97
|
+
|
|
98
|
+
CARDINALITY: 'Cardinality'
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* ECL Lexer - Tokenizes ECL expressions
|
|
103
|
+
*/
|
|
104
|
+
class ECLLexer {
|
|
105
|
+
constructor(input) {
|
|
106
|
+
this.input = input;
|
|
107
|
+
this.position = 0;
|
|
108
|
+
this.current = this.input[0] || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
error(message) {
|
|
112
|
+
throw new Error(`Lexer error at position ${this.position}: ${message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
advance() {
|
|
116
|
+
this.position++;
|
|
117
|
+
this.current = this.position < this.input.length ? this.input[this.position] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
peek(offset = 1) {
|
|
121
|
+
const pos = this.position + offset;
|
|
122
|
+
return pos < this.input.length ? this.input[pos] : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
skipWhitespace() {
|
|
126
|
+
while (this.current && /\s/.test(this.current)) {
|
|
127
|
+
this.advance();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
readSCTID() {
|
|
132
|
+
let value = '';
|
|
133
|
+
while (this.current && /\d/.test(this.current)) {
|
|
134
|
+
value += this.current;
|
|
135
|
+
this.advance();
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
readTerm() {
|
|
141
|
+
let value = '';
|
|
142
|
+
// We should be positioned at the opening |
|
|
143
|
+
if (this.current !== '|') {
|
|
144
|
+
this.error('Expected | at start of term');
|
|
145
|
+
}
|
|
146
|
+
this.advance(); // Skip opening |
|
|
147
|
+
|
|
148
|
+
while (this.current && this.current !== '|') {
|
|
149
|
+
value += this.current;
|
|
150
|
+
this.advance();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.current !== '|') {
|
|
154
|
+
this.error('Unterminated term - missing closing |');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.advance(); // Skip closing |
|
|
158
|
+
return value.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
readString() {
|
|
162
|
+
let value = '';
|
|
163
|
+
const quote = this.current;
|
|
164
|
+
this.advance(); // Skip opening quote
|
|
165
|
+
|
|
166
|
+
while (this.current && this.current !== quote) {
|
|
167
|
+
if (this.current === '\\') {
|
|
168
|
+
this.advance();
|
|
169
|
+
if (this.current) {
|
|
170
|
+
value += this.current;
|
|
171
|
+
this.advance();
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
value += this.current;
|
|
175
|
+
this.advance();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (this.current !== quote) {
|
|
180
|
+
this.error('Unterminated string');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.advance(); // Skip closing quote
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
readNumber() {
|
|
188
|
+
let value = '';
|
|
189
|
+
let hasDecimal = false;
|
|
190
|
+
|
|
191
|
+
// Handle negative numbers
|
|
192
|
+
if (this.current === '-') {
|
|
193
|
+
value += this.current;
|
|
194
|
+
this.advance();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
while (this.current && (/\d/.test(this.current) || (this.current === '.' && !hasDecimal))) {
|
|
198
|
+
if (this.current === '.') {
|
|
199
|
+
hasDecimal = true;
|
|
200
|
+
}
|
|
201
|
+
value += this.current;
|
|
202
|
+
this.advance();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
value,
|
|
207
|
+
type: hasDecimal ? ECLTokenType.DECIMAL : ECLTokenType.INTEGER
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
getNextToken() {
|
|
212
|
+
while (this.current) {
|
|
213
|
+
if (/\s/.test(this.current)) {
|
|
214
|
+
this.skipWhitespace();
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Single character tokens
|
|
219
|
+
switch (this.current) {
|
|
220
|
+
case '(':
|
|
221
|
+
this.advance();
|
|
222
|
+
return { type: ECLTokenType.LPAREN, value: '(' };
|
|
223
|
+
case ')':
|
|
224
|
+
this.advance();
|
|
225
|
+
return { type: ECLTokenType.RPAREN, value: ')' };
|
|
226
|
+
case '{':
|
|
227
|
+
this.advance();
|
|
228
|
+
return { type: ECLTokenType.LBRACE, value: '{' };
|
|
229
|
+
case '}':
|
|
230
|
+
this.advance();
|
|
231
|
+
return { type: ECLTokenType.RBRACE, value: '}' };
|
|
232
|
+
case '[':
|
|
233
|
+
this.advance();
|
|
234
|
+
return { type: ECLTokenType.LBRACKET, value: '[' };
|
|
235
|
+
case ']':
|
|
236
|
+
this.advance();
|
|
237
|
+
return { type: ECLTokenType.RBRACKET, value: ']' };
|
|
238
|
+
case ',':
|
|
239
|
+
this.advance();
|
|
240
|
+
return { type: ECLTokenType.COMMA, value: ',' };
|
|
241
|
+
case '.':
|
|
242
|
+
// Check for cardinality range operator (..)
|
|
243
|
+
if (this.peek() === '.') {
|
|
244
|
+
this.advance();
|
|
245
|
+
this.advance();
|
|
246
|
+
return { type: ECLTokenType.CARDINALITY_RANGE, value: '..' };
|
|
247
|
+
} else {
|
|
248
|
+
this.advance();
|
|
249
|
+
return { type: ECLTokenType.DOT, value: '.' };
|
|
250
|
+
}
|
|
251
|
+
case ':':
|
|
252
|
+
this.advance();
|
|
253
|
+
return { type: ECLTokenType.COLON, value: ':' };
|
|
254
|
+
case '^':
|
|
255
|
+
this.advance();
|
|
256
|
+
return { type: ECLTokenType.MEMBER_OF, value: '^' };
|
|
257
|
+
case '*':
|
|
258
|
+
this.advance();
|
|
259
|
+
return { type: ECLTokenType.WILDCARD, value: '*' };
|
|
260
|
+
case '#':
|
|
261
|
+
this.advance();
|
|
262
|
+
return { type: ECLTokenType.HASH, value: '#' };
|
|
263
|
+
case '|': {
|
|
264
|
+
// This is a term - read the entire |term| construct
|
|
265
|
+
const termValue = this.readTerm();
|
|
266
|
+
return {type: ECLTokenType.TERM, value: termValue};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Multi-character operators
|
|
271
|
+
if (this.current === '<') {
|
|
272
|
+
if (this.peek() === '<') {
|
|
273
|
+
if (this.peek(2) === '!') {
|
|
274
|
+
this.advance();
|
|
275
|
+
this.advance();
|
|
276
|
+
this.advance();
|
|
277
|
+
return { type: ECLTokenType.DESCENDANT_OR_SELF_OF, value: '<<!', };
|
|
278
|
+
} else {
|
|
279
|
+
this.advance();
|
|
280
|
+
this.advance();
|
|
281
|
+
return { type: ECLTokenType.CHILD_OR_SELF_OF, value: '<<' };
|
|
282
|
+
}
|
|
283
|
+
} else if (this.peek() === '!') {
|
|
284
|
+
this.advance();
|
|
285
|
+
this.advance();
|
|
286
|
+
return { type: ECLTokenType.DESCENDANT_OF, value: '<!' };
|
|
287
|
+
} else if (this.peek() === '=') {
|
|
288
|
+
this.advance();
|
|
289
|
+
this.advance();
|
|
290
|
+
return { type: ECLTokenType.LTE, value: '<=' };
|
|
291
|
+
} else {
|
|
292
|
+
this.advance();
|
|
293
|
+
return { type: ECLTokenType.CHILD_OF, value: '<' };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (this.current === '>') {
|
|
298
|
+
if (this.peek() === '>') {
|
|
299
|
+
if (this.peek(2) === '!') {
|
|
300
|
+
this.advance();
|
|
301
|
+
this.advance();
|
|
302
|
+
this.advance();
|
|
303
|
+
return { type: ECLTokenType.ANCESTOR_OR_SELF_OF, value: '>>!' };
|
|
304
|
+
} else {
|
|
305
|
+
this.advance();
|
|
306
|
+
this.advance();
|
|
307
|
+
return { type: ECLTokenType.PARENT_OR_SELF_OF, value: '>>' };
|
|
308
|
+
}
|
|
309
|
+
} else if (this.peek() === '!') {
|
|
310
|
+
this.advance();
|
|
311
|
+
this.advance();
|
|
312
|
+
return { type: ECLTokenType.ANCESTOR_OF, value: '>!' };
|
|
313
|
+
} else if (this.peek() === '=') {
|
|
314
|
+
this.advance();
|
|
315
|
+
this.advance();
|
|
316
|
+
return { type: ECLTokenType.GTE, value: '>=' };
|
|
317
|
+
} else {
|
|
318
|
+
this.advance();
|
|
319
|
+
return { type: ECLTokenType.PARENT_OF, value: '>' };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (this.current === '=') {
|
|
324
|
+
this.advance();
|
|
325
|
+
return { type: ECLTokenType.EQUALS, value: '=' };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this.current === '!') {
|
|
329
|
+
if (this.peek() === '=') {
|
|
330
|
+
this.advance();
|
|
331
|
+
this.advance();
|
|
332
|
+
return { type: ECLTokenType.NOT_EQUALS, value: '!=' };
|
|
333
|
+
} else {
|
|
334
|
+
this.error(`Unexpected character: ${this.current}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// String literals
|
|
339
|
+
if (this.current === '"' || this.current === "'") {
|
|
340
|
+
return { type: ECLTokenType.STRING, value: this.readString() };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Handle numbers - check for decimal first, then SCTID
|
|
344
|
+
if (/\d/.test(this.current)) {
|
|
345
|
+
// Look ahead to see if this is a decimal number (digit.digit with no space)
|
|
346
|
+
let pos = this.position;
|
|
347
|
+
while (pos < this.input.length && /\d/.test(this.input[pos])) {
|
|
348
|
+
pos++;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Check if immediately followed by .digit (decimal number)
|
|
352
|
+
if (pos < this.input.length &&
|
|
353
|
+
this.input[pos] === '.' &&
|
|
354
|
+
pos + 1 < this.input.length &&
|
|
355
|
+
/\d/.test(this.input[pos + 1])) {
|
|
356
|
+
// This is a decimal number - parse it completely
|
|
357
|
+
const num = this.readNumber();
|
|
358
|
+
return { type: num.type, value: num.value };
|
|
359
|
+
} else {
|
|
360
|
+
// This is a SCTID (concept ID)
|
|
361
|
+
const value = this.readSCTID();
|
|
362
|
+
return { type: ECLTokenType.SCTID, value };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Handle negative numbers separately
|
|
367
|
+
if (this.current === '-' && /\d/.test(this.peek())) {
|
|
368
|
+
const num = this.readNumber();
|
|
369
|
+
return { type: num.type, value: num.value };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Keywords and identifiers
|
|
373
|
+
if (/[a-zA-Z_]/.test(this.current)) {
|
|
374
|
+
let value = '';
|
|
375
|
+
while (this.current && /[a-zA-Z0-9_]/.test(this.current)) {
|
|
376
|
+
value += this.current;
|
|
377
|
+
this.advance();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Check for keywords
|
|
381
|
+
switch (value.toUpperCase()) {
|
|
382
|
+
case 'AND':
|
|
383
|
+
return { type: ECLTokenType.AND, value: value };
|
|
384
|
+
case 'OR':
|
|
385
|
+
return { type: ECLTokenType.OR, value: value };
|
|
386
|
+
case 'MINUS':
|
|
387
|
+
return { type: ECLTokenType.MINUS, value: value };
|
|
388
|
+
case 'R':
|
|
389
|
+
return { type: ECLTokenType.REVERSE, value: value };
|
|
390
|
+
default:
|
|
391
|
+
// Could be a namespace identifier or other construct
|
|
392
|
+
return { type: ECLTokenType.SCTID, value: value };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
this.error(`Unexpected character: ${this.current}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { type: ECLTokenType.EOF, value: null };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
tokenize() {
|
|
403
|
+
const tokens = [];
|
|
404
|
+
let token;
|
|
405
|
+
|
|
406
|
+
do {
|
|
407
|
+
token = this.getNextToken();
|
|
408
|
+
tokens.push(token);
|
|
409
|
+
} while (token.type !== ECLTokenType.EOF);
|
|
410
|
+
|
|
411
|
+
return tokens;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* ECL Parser - Parses tokens into AST
|
|
417
|
+
*/
|
|
418
|
+
class ECLParser {
|
|
419
|
+
constructor(tokens) {
|
|
420
|
+
this.tokens = tokens;
|
|
421
|
+
this.position = 0;
|
|
422
|
+
this.current = this.tokens[0] || { type: ECLTokenType.EOF };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
error(message) {
|
|
426
|
+
throw new Error(`Parser error at token ${this.position}: ${message}. Current token: ${this.current.type}(${this.current.value})`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
advance() {
|
|
430
|
+
this.position++;
|
|
431
|
+
this.current = this.position < this.tokens.length ? this.tokens[this.position] : { type: ECLTokenType.EOF };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
peek(offset = 1) {
|
|
435
|
+
const pos = this.position + offset;
|
|
436
|
+
return pos < this.tokens.length ? this.tokens[pos] : { type: ECLTokenType.EOF };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
expect(tokenType) {
|
|
440
|
+
if (this.current.type !== tokenType) {
|
|
441
|
+
this.error(`Expected ${tokenType}, got ${this.current.type}`);
|
|
442
|
+
}
|
|
443
|
+
const token = this.current;
|
|
444
|
+
this.advance();
|
|
445
|
+
return token;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
match(...tokenTypes) {
|
|
449
|
+
return tokenTypes.includes(this.current.type);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Main parsing entry point
|
|
453
|
+
parse() {
|
|
454
|
+
const result = this.parseExpressionConstraint();
|
|
455
|
+
if (this.current.type !== ECLTokenType.EOF) {
|
|
456
|
+
this.error('Unexpected tokens after end of expression');
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
parseExpressionConstraint() {
|
|
462
|
+
return this.parseCompoundExpressionConstraint();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
parseCompoundExpressionConstraint() {
|
|
466
|
+
let left = this.parseRefinedExpressionConstraint();
|
|
467
|
+
|
|
468
|
+
while (this.match(ECLTokenType.AND, ECLTokenType.OR, ECLTokenType.MINUS)) {
|
|
469
|
+
const operator = this.current;
|
|
470
|
+
this.advance();
|
|
471
|
+
const right = this.parseRefinedExpressionConstraint();
|
|
472
|
+
|
|
473
|
+
const nodeType = operator.type === ECLTokenType.AND ? ECLNodeType.CONJUNCTION :
|
|
474
|
+
operator.type === ECLTokenType.OR ? ECLNodeType.DISJUNCTION :
|
|
475
|
+
ECLNodeType.EXCLUSION;
|
|
476
|
+
|
|
477
|
+
left = {
|
|
478
|
+
type: ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT,
|
|
479
|
+
operator: nodeType,
|
|
480
|
+
left,
|
|
481
|
+
right
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return left;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
parseRefinedExpressionConstraint() {
|
|
489
|
+
let base = this.parseDottedExpressionConstraint();
|
|
490
|
+
|
|
491
|
+
if (this.match(ECLTokenType.COLON)) {
|
|
492
|
+
this.advance(); // consume :
|
|
493
|
+
const refinement = this.parseRefinement();
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
type: ECLNodeType.REFINED_EXPRESSION_CONSTRAINT,
|
|
497
|
+
base,
|
|
498
|
+
refinement
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return base;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
parseDottedExpressionConstraint() {
|
|
506
|
+
let base = this.parseSubExpressionConstraint();
|
|
507
|
+
|
|
508
|
+
const attributes = [];
|
|
509
|
+
while (this.match(ECLTokenType.DOT)) {
|
|
510
|
+
this.advance(); // consume .
|
|
511
|
+
const attribute = this.parseAttributeName();
|
|
512
|
+
attributes.push(attribute);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (attributes.length > 0) {
|
|
516
|
+
return {
|
|
517
|
+
type: ECLNodeType.DOTTED_EXPRESSION_CONSTRAINT,
|
|
518
|
+
base,
|
|
519
|
+
attributes
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return base;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
parseSubExpressionConstraint() {
|
|
527
|
+
// Handle constraint operators
|
|
528
|
+
let operator = null;
|
|
529
|
+
if (this.match(
|
|
530
|
+
ECLTokenType.CHILD_OF, ECLTokenType.CHILD_OR_SELF_OF,
|
|
531
|
+
ECLTokenType.DESCENDANT_OF, ECLTokenType.DESCENDANT_OR_SELF_OF,
|
|
532
|
+
ECLTokenType.PARENT_OF, ECLTokenType.PARENT_OR_SELF_OF,
|
|
533
|
+
ECLTokenType.ANCESTOR_OF, ECLTokenType.ANCESTOR_OR_SELF_OF
|
|
534
|
+
)) {
|
|
535
|
+
operator = this.current;
|
|
536
|
+
this.advance();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let focus;
|
|
540
|
+
|
|
541
|
+
// Handle memberOf
|
|
542
|
+
if (this.match(ECLTokenType.MEMBER_OF)) {
|
|
543
|
+
this.advance(); // consume ^
|
|
544
|
+
// Parse the reference set - can be concept reference, wildcard, or parenthesized expression
|
|
545
|
+
// but NOT another constraint operator or member-of expression
|
|
546
|
+
let refSet;
|
|
547
|
+
if (this.match(ECLTokenType.LPAREN)) {
|
|
548
|
+
this.advance(); // consume (
|
|
549
|
+
refSet = this.parseExpressionConstraint();
|
|
550
|
+
this.expect(ECLTokenType.RPAREN);
|
|
551
|
+
} else {
|
|
552
|
+
refSet = this.parseEclFocusConcept();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
focus = {
|
|
556
|
+
type: ECLNodeType.MEMBER_OF,
|
|
557
|
+
refSet
|
|
558
|
+
};
|
|
559
|
+
} else if (this.match(ECLTokenType.LPAREN)) {
|
|
560
|
+
this.advance(); // consume (
|
|
561
|
+
focus = this.parseExpressionConstraint();
|
|
562
|
+
this.expect(ECLTokenType.RPAREN);
|
|
563
|
+
} else {
|
|
564
|
+
focus = this.parseEclFocusConcept();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const result = {
|
|
568
|
+
type: ECLNodeType.SUB_EXPRESSION_CONSTRAINT,
|
|
569
|
+
operator: operator ? operator.type : null,
|
|
570
|
+
focus
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
parseEclFocusConcept() {
|
|
577
|
+
if (this.match(ECLTokenType.WILDCARD)) {
|
|
578
|
+
this.advance();
|
|
579
|
+
return {
|
|
580
|
+
type: ECLNodeType.WILDCARD
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (this.match(ECLTokenType.SCTID)) {
|
|
585
|
+
const conceptId = this.current.value;
|
|
586
|
+
this.advance();
|
|
587
|
+
|
|
588
|
+
let term = null;
|
|
589
|
+
if (this.match(ECLTokenType.TERM)) {
|
|
590
|
+
term = this.current.value;
|
|
591
|
+
this.advance();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
type: ECLNodeType.CONCEPT_REFERENCE,
|
|
596
|
+
conceptId,
|
|
597
|
+
term
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
this.error('Expected concept reference or wildcard');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
parseRefinement() {
|
|
605
|
+
return this.parseAttributeSet();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
parseAttributeSet() {
|
|
609
|
+
const attributes = [];
|
|
610
|
+
|
|
611
|
+
do {
|
|
612
|
+
if (this.match(ECLTokenType.LBRACE)) {
|
|
613
|
+
// Attribute group
|
|
614
|
+
attributes.push(this.parseAttributeGroup());
|
|
615
|
+
} else {
|
|
616
|
+
// Single attribute
|
|
617
|
+
attributes.push(this.parseAttribute());
|
|
618
|
+
}
|
|
619
|
+
} while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
|
|
620
|
+
|
|
621
|
+
if (attributes.length === 1) {
|
|
622
|
+
return attributes[0];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
type: ECLNodeType.ATTRIBUTE_SET,
|
|
627
|
+
attributes
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
parseAttributeGroup() {
|
|
632
|
+
let cardinality = null;
|
|
633
|
+
|
|
634
|
+
// Check for cardinality before {
|
|
635
|
+
if (this.match(ECLTokenType.LBRACKET)) {
|
|
636
|
+
cardinality = this.parseCardinality();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
this.expect(ECLTokenType.LBRACE);
|
|
640
|
+
|
|
641
|
+
const attributes = [];
|
|
642
|
+
do {
|
|
643
|
+
attributes.push(this.parseAttribute());
|
|
644
|
+
} while (this.match(ECLTokenType.COMMA) && (this.advance(), true));
|
|
645
|
+
|
|
646
|
+
this.expect(ECLTokenType.RBRACE);
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
type: ECLNodeType.ATTRIBUTE_GROUP,
|
|
650
|
+
cardinality,
|
|
651
|
+
attributes
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
parseAttribute() {
|
|
656
|
+
let cardinality = null;
|
|
657
|
+
let reverse = false;
|
|
658
|
+
|
|
659
|
+
// Check for cardinality first - this must come before attribute name
|
|
660
|
+
if (this.match(ECLTokenType.LBRACKET)) {
|
|
661
|
+
cardinality = this.parseCardinality();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Check for reverse flag
|
|
665
|
+
if (this.match(ECLTokenType.REVERSE)) {
|
|
666
|
+
reverse = true;
|
|
667
|
+
this.advance();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const name = this.parseAttributeName();
|
|
671
|
+
|
|
672
|
+
// Parse comparison operator and value
|
|
673
|
+
let comparison = null;
|
|
674
|
+
if (this.match(ECLTokenType.EQUALS, ECLTokenType.NOT_EQUALS)) {
|
|
675
|
+
const operator = this.current;
|
|
676
|
+
this.advance();
|
|
677
|
+
const value = this.parseSubExpressionConstraint();
|
|
678
|
+
|
|
679
|
+
comparison = {
|
|
680
|
+
type: ECLNodeType.EXPRESSION_COMPARISON,
|
|
681
|
+
operator: operator.type,
|
|
682
|
+
value
|
|
683
|
+
};
|
|
684
|
+
} else if (this.match(ECLTokenType.LT, ECLTokenType.LTE, ECLTokenType.CHILD_OF, ECLTokenType.PARENT_OF, ECLTokenType.GTE)) {
|
|
685
|
+
// Note: CHILD_OF (<) is treated as LT and PARENT_OF (>) is treated as GT in numeric comparison context
|
|
686
|
+
const operator = this.current;
|
|
687
|
+
this.advance();
|
|
688
|
+
|
|
689
|
+
this.expect(ECLTokenType.HASH);
|
|
690
|
+
|
|
691
|
+
let value;
|
|
692
|
+
if (this.match(ECLTokenType.SCTID, ECLTokenType.DECIMAL, ECLTokenType.INTEGER)) {
|
|
693
|
+
// In numeric comparison context, accept SCTID, DECIMAL, or INTEGER as numbers
|
|
694
|
+
value = this.current.value;
|
|
695
|
+
this.advance();
|
|
696
|
+
} else {
|
|
697
|
+
this.error('Expected numeric value after #');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Map CHILD_OF to LT and PARENT_OF to GT for numeric comparisons
|
|
701
|
+
let operatorType = operator.type;
|
|
702
|
+
if (operator.type === ECLTokenType.CHILD_OF) {
|
|
703
|
+
operatorType = ECLTokenType.LT;
|
|
704
|
+
} else if (operator.type === ECLTokenType.PARENT_OF) {
|
|
705
|
+
operatorType = ECLTokenType.GT;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
comparison = {
|
|
709
|
+
type: ECLNodeType.NUMERIC_COMPARISON,
|
|
710
|
+
operator: operatorType,
|
|
711
|
+
value
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
type: ECLNodeType.ATTRIBUTE,
|
|
717
|
+
cardinality,
|
|
718
|
+
reverse,
|
|
719
|
+
name,
|
|
720
|
+
comparison
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
parseAttributeName() {
|
|
725
|
+
return this.parseEclFocusConcept();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
parseCardinality() {
|
|
729
|
+
this.expect(ECLTokenType.LBRACKET);
|
|
730
|
+
|
|
731
|
+
let min = null;
|
|
732
|
+
let max = null;
|
|
733
|
+
|
|
734
|
+
if (this.match(ECLTokenType.SCTID)) {
|
|
735
|
+
// Parse as number in cardinality context
|
|
736
|
+
min = parseInt(this.current.value);
|
|
737
|
+
this.advance();
|
|
738
|
+
|
|
739
|
+
// Check for range syntax: ..
|
|
740
|
+
if (this.match(ECLTokenType.CARDINALITY_RANGE)) {
|
|
741
|
+
this.advance(); // consume ..
|
|
742
|
+
|
|
743
|
+
if (this.match(ECLTokenType.SCTID)) {
|
|
744
|
+
max = parseInt(this.current.value);
|
|
745
|
+
this.advance();
|
|
746
|
+
} else if (this.match(ECLTokenType.WILDCARD)) {
|
|
747
|
+
max = '*';
|
|
748
|
+
this.advance();
|
|
749
|
+
} else {
|
|
750
|
+
this.error('Expected number or * after ..');
|
|
751
|
+
}
|
|
752
|
+
} else {
|
|
753
|
+
max = min; // Single number means exact cardinality
|
|
754
|
+
}
|
|
755
|
+
} else if (this.match(ECLTokenType.WILDCARD)) {
|
|
756
|
+
min = 0;
|
|
757
|
+
max = '*';
|
|
758
|
+
this.advance();
|
|
759
|
+
} else {
|
|
760
|
+
this.error('Expected number or * for cardinality');
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
this.expect(ECLTokenType.RBRACKET);
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
type: ECLNodeType.CARDINALITY,
|
|
767
|
+
min,
|
|
768
|
+
max
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* ECL Validator - Validates and evaluates ECL expressions
|
|
775
|
+
*/
|
|
776
|
+
class ECLValidator {
|
|
777
|
+
constructor(snomedServices) {
|
|
778
|
+
this.sct = snomedServices;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Parse and validate an ECL expression
|
|
783
|
+
*/
|
|
784
|
+
parse(eclExpression) {
|
|
785
|
+
try {
|
|
786
|
+
const lexer = new ECLLexer(eclExpression);
|
|
787
|
+
const tokens = lexer.tokenize();
|
|
788
|
+
|
|
789
|
+
const parser = new ECLParser(tokens);
|
|
790
|
+
const ast = parser.parse();
|
|
791
|
+
|
|
792
|
+
// Validate the AST
|
|
793
|
+
this.validateAST(ast);
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
success: true,
|
|
797
|
+
ast,
|
|
798
|
+
errors: []
|
|
799
|
+
};
|
|
800
|
+
} catch (error) {
|
|
801
|
+
return {
|
|
802
|
+
success: false,
|
|
803
|
+
ast: null,
|
|
804
|
+
errors: [error.message]
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Validate AST node semantically
|
|
811
|
+
*/
|
|
812
|
+
validateAST(node) {
|
|
813
|
+
if (!node || typeof node !== 'object') {
|
|
814
|
+
throw new Error('Invalid AST node');
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
switch (node.type) {
|
|
818
|
+
case ECLNodeType.CONCEPT_REFERENCE:
|
|
819
|
+
this.validateConceptReference(node);
|
|
820
|
+
break;
|
|
821
|
+
|
|
822
|
+
case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT:
|
|
823
|
+
this.validateAST(node.base);
|
|
824
|
+
this.validateAST(node.refinement);
|
|
825
|
+
break;
|
|
826
|
+
|
|
827
|
+
case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT:
|
|
828
|
+
this.validateAST(node.left);
|
|
829
|
+
this.validateAST(node.right);
|
|
830
|
+
break;
|
|
831
|
+
|
|
832
|
+
case ECLNodeType.DOTTED_EXPRESSION_CONSTRAINT:
|
|
833
|
+
this.validateAST(node.base);
|
|
834
|
+
node.attributes.forEach(attr => this.validateAST(attr));
|
|
835
|
+
break;
|
|
836
|
+
|
|
837
|
+
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
|
|
838
|
+
this.validateAST(node.focus);
|
|
839
|
+
break;
|
|
840
|
+
|
|
841
|
+
case ECLNodeType.MEMBER_OF:
|
|
842
|
+
this.validateAST(node.refSet);
|
|
843
|
+
this.validateReferenceSet(node.refSet);
|
|
844
|
+
break;
|
|
845
|
+
|
|
846
|
+
case ECLNodeType.ATTRIBUTE_SET:
|
|
847
|
+
node.attributes.forEach(attr => this.validateAST(attr));
|
|
848
|
+
break;
|
|
849
|
+
|
|
850
|
+
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
851
|
+
node.attributes.forEach(attr => this.validateAST(attr));
|
|
852
|
+
break;
|
|
853
|
+
|
|
854
|
+
case ECLNodeType.ATTRIBUTE:
|
|
855
|
+
this.validateAST(node.name);
|
|
856
|
+
if (node.comparison) {
|
|
857
|
+
this.validateComparison(node.comparison);
|
|
858
|
+
}
|
|
859
|
+
break;
|
|
860
|
+
|
|
861
|
+
case ECLNodeType.EXPRESSION_COMPARISON:
|
|
862
|
+
this.validateAST(node.value);
|
|
863
|
+
break;
|
|
864
|
+
|
|
865
|
+
case ECLNodeType.WILDCARD:
|
|
866
|
+
// Wildcards are always valid
|
|
867
|
+
break;
|
|
868
|
+
|
|
869
|
+
default:
|
|
870
|
+
// Allow other node types to pass through
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Validate concept reference exists in SNOMED CT
|
|
877
|
+
*/
|
|
878
|
+
/**
|
|
879
|
+
* Validate concept reference exists in SNOMED CT and term matches if provided
|
|
880
|
+
*/
|
|
881
|
+
validateConceptReference(node) {
|
|
882
|
+
if (!node.conceptId) {
|
|
883
|
+
throw new Error('Concept reference missing concept ID');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!/^\d+$/.test(node.conceptId)) {
|
|
887
|
+
throw new Error(`Invalid SNOMED CT concept ID format: ${node.conceptId}`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Check if it's a valid concept ID
|
|
891
|
+
try {
|
|
892
|
+
if (!this.sct.conceptExists(node.conceptId)) {
|
|
893
|
+
// Check if it might be a number used in wrong context
|
|
894
|
+
const numValue = parseInt(node.conceptId);
|
|
895
|
+
if (numValue < 1000000) {
|
|
896
|
+
throw new Error(`${node.conceptId} appears to be a number rather than a SNOMED CT concept ID. SNOMED concept IDs are typically 6+ digits. If this should be a numeric value, use it in a cardinality [${node.conceptId}] or comparison >= #${node.conceptId} context.`);
|
|
897
|
+
} else {
|
|
898
|
+
throw new Error(`SNOMED CT concept ${node.conceptId} not found in the loaded terminology`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
} catch (error) {
|
|
902
|
+
if (error.message.includes('not found')) {
|
|
903
|
+
throw error; // Re-throw our custom message
|
|
904
|
+
}
|
|
905
|
+
throw new Error(`Error validating concept ${node.conceptId}: ${error.message}`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Validate term if provided
|
|
909
|
+
if (node.term) {
|
|
910
|
+
try {
|
|
911
|
+
const conceptIndex = this.getConceptReference(node.conceptId);
|
|
912
|
+
const concept = this.sct.concepts.getConcept(conceptIndex);
|
|
913
|
+
const descriptionsRef = concept.descriptions;
|
|
914
|
+
|
|
915
|
+
if (descriptionsRef === 0) {
|
|
916
|
+
throw new Error(`Concept ${node.conceptId} has no descriptions`);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const descriptionIndices = this.sct.refs.getReferences(descriptionsRef);
|
|
920
|
+
let termFound = false;
|
|
921
|
+
|
|
922
|
+
const list = [];
|
|
923
|
+
// Check if the provided term matches any of the concept's descriptions
|
|
924
|
+
for (const descIndex of descriptionIndices) {
|
|
925
|
+
const description = this.sct.descriptions.getDescription(descIndex);
|
|
926
|
+
const actualTerm = this.sct.strings.getEntry(description.iDesc).trim();
|
|
927
|
+
list.push(actualTerm);
|
|
928
|
+
if (actualTerm === node.term.trim()) {
|
|
929
|
+
termFound = true;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (!termFound) {
|
|
935
|
+
// Get the preferred term for a more helpful error message
|
|
936
|
+
const preferredTerm = this.sct.getDisplayName(conceptIndex);
|
|
937
|
+
throw new Error(`Term "${node.term}" does not match any active description for concept ${node.conceptId}. Expected term like "${preferredTerm}" or from ${list}`);
|
|
938
|
+
}
|
|
939
|
+
} catch (error) {
|
|
940
|
+
if (error.message.includes('does not match')) {
|
|
941
|
+
throw error; // Re-throw our term validation error
|
|
942
|
+
}
|
|
943
|
+
throw new Error(`Error validating term for concept ${node.conceptId}: ${error.message}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Validate reference set concept
|
|
949
|
+
*/
|
|
950
|
+
validateReferenceSet(node) {
|
|
951
|
+
if (node.type === ECLNodeType.CONCEPT_REFERENCE) {
|
|
952
|
+
const conceptIndex = this.getConceptReference(node.conceptId);
|
|
953
|
+
const refSetIndex = this.sct.getConceptRefSet(conceptIndex, false);
|
|
954
|
+
if (refSetIndex === 0) {
|
|
955
|
+
throw new Error(`Concept ${node.conceptId} is not a reference set`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Validate comparison expressions
|
|
962
|
+
*/
|
|
963
|
+
validateComparison(comparison) {
|
|
964
|
+
switch (comparison.type) {
|
|
965
|
+
case ECLNodeType.EXPRESSION_COMPARISON:
|
|
966
|
+
this.validateAST(comparison.value);
|
|
967
|
+
break;
|
|
968
|
+
|
|
969
|
+
case ECLNodeType.NUMERIC_COMPARISON:
|
|
970
|
+
if (!/^-?\d+(\.\d+)?$/.test(comparison.value)) {
|
|
971
|
+
throw new Error(`Invalid numeric value: ${comparison.value}`);
|
|
972
|
+
}
|
|
973
|
+
break;
|
|
974
|
+
|
|
975
|
+
case ECLNodeType.STRING_COMPARISON:
|
|
976
|
+
// String values are always valid
|
|
977
|
+
break;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Evaluate ECL expression and return matching concepts
|
|
983
|
+
*/
|
|
984
|
+
async evaluate(eclExpression, options = {}) {
|
|
985
|
+
const parseResult = this.parse(eclExpression);
|
|
986
|
+
|
|
987
|
+
if (!parseResult.success) {
|
|
988
|
+
throw new Error(`ECL parsing failed: ${parseResult.errors.join(', ')}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const filterContext = await this.evaluateAST(parseResult.ast, options);
|
|
992
|
+
return this.convertFilterToResults(filterContext);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Evaluate AST node and return filter context
|
|
997
|
+
*/
|
|
998
|
+
async evaluateAST(node = {}) {
|
|
999
|
+
if (!node) {
|
|
1000
|
+
throw new Error('Cannot evaluate null AST node');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
switch (node.type) {
|
|
1004
|
+
case ECLNodeType.CONCEPT_REFERENCE:
|
|
1005
|
+
return await this.evaluateConceptReference(node);
|
|
1006
|
+
|
|
1007
|
+
case ECLNodeType.WILDCARD:
|
|
1008
|
+
return await this.evaluateWildcard();
|
|
1009
|
+
|
|
1010
|
+
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
|
|
1011
|
+
return await this.evaluateSubExpressionConstraint(node);
|
|
1012
|
+
|
|
1013
|
+
case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT:
|
|
1014
|
+
return await this.evaluateCompoundExpression(node);
|
|
1015
|
+
|
|
1016
|
+
case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT:
|
|
1017
|
+
return await this.evaluateRefinedExpression(node);
|
|
1018
|
+
|
|
1019
|
+
case ECLNodeType.MEMBER_OF:
|
|
1020
|
+
return await this.evaluateMemberOf(node);
|
|
1021
|
+
|
|
1022
|
+
default:
|
|
1023
|
+
throw new Error(`Unsupported AST node type: ${node.type}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async evaluateConceptReference(node) {
|
|
1028
|
+
const conceptId = this.sct.stringToId(node.conceptId);
|
|
1029
|
+
return this.sct.filterEquals(conceptId);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async evaluateWildcard() {
|
|
1033
|
+
// Return all concepts - this would need optimization in practice
|
|
1034
|
+
const filter = new SnomedFilterContext();
|
|
1035
|
+
const allConcepts = [];
|
|
1036
|
+
|
|
1037
|
+
for (let i = 0; i < this.sct.concepts.count(); i++) {
|
|
1038
|
+
const conceptIndex = i * this.sct.concepts.constructor.CONCEPT_SIZE;
|
|
1039
|
+
if (this.sct.isActive(conceptIndex)) {
|
|
1040
|
+
allConcepts.push(conceptIndex);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
filter.descendants = allConcepts;
|
|
1045
|
+
return filter;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async evaluateSubExpressionConstraint(node) {
|
|
1049
|
+
const baseFilter = await this.evaluateAST(node.focus);
|
|
1050
|
+
|
|
1051
|
+
if (!node.operator) {
|
|
1052
|
+
return baseFilter;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Apply constraint operator
|
|
1056
|
+
const results = new SnomedFilterContext();
|
|
1057
|
+
|
|
1058
|
+
for (const conceptIndex of baseFilter.descendants || []) {
|
|
1059
|
+
const conceptId = this.sct.concepts.getConceptId(conceptIndex);
|
|
1060
|
+
|
|
1061
|
+
let operatorFilter;
|
|
1062
|
+
switch (node.operator) {
|
|
1063
|
+
case ECLTokenType.CHILD_OF:
|
|
1064
|
+
operatorFilter = this.sct.filterIsA(conceptId, false);
|
|
1065
|
+
break;
|
|
1066
|
+
case ECLTokenType.CHILD_OR_SELF_OF:
|
|
1067
|
+
operatorFilter = this.sct.filterIsA(conceptId, true);
|
|
1068
|
+
break;
|
|
1069
|
+
case ECLTokenType.DESCENDANT_OF:
|
|
1070
|
+
operatorFilter = this.sct.filterIsA(conceptId, false);
|
|
1071
|
+
break;
|
|
1072
|
+
case ECLTokenType.DESCENDANT_OR_SELF_OF:
|
|
1073
|
+
operatorFilter = this.sct.filterIsA(conceptId, true);
|
|
1074
|
+
break;
|
|
1075
|
+
case ECLTokenType.PARENT_OF:
|
|
1076
|
+
case ECLTokenType.PARENT_OR_SELF_OF:
|
|
1077
|
+
case ECLTokenType.ANCESTOR_OF:
|
|
1078
|
+
case ECLTokenType.ANCESTOR_OR_SELF_OF:
|
|
1079
|
+
// These would require reverse hierarchy traversal
|
|
1080
|
+
throw new Error(`Operator ${node.operator} not yet implemented`);
|
|
1081
|
+
default:
|
|
1082
|
+
throw new Error(`Unknown constraint operator: ${node.operator}`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
results.descendants = [...(results.descendants || []), ...(operatorFilter.descendants || [])];
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return results;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async evaluateCompoundExpression(node) {
|
|
1092
|
+
const leftFilter = await this.evaluateAST(node.left);
|
|
1093
|
+
const rightFilter = await this.evaluateAST(node.right);
|
|
1094
|
+
|
|
1095
|
+
const result = new SnomedFilterContext();
|
|
1096
|
+
const leftSet = new Set(leftFilter.descendants || []);
|
|
1097
|
+
const rightSet = new Set(rightFilter.descendants || []);
|
|
1098
|
+
|
|
1099
|
+
switch (node.operator) {
|
|
1100
|
+
case ECLNodeType.CONJUNCTION:
|
|
1101
|
+
result.descendants = [...leftSet].filter(x => rightSet.has(x));
|
|
1102
|
+
break;
|
|
1103
|
+
case ECLNodeType.DISJUNCTION:
|
|
1104
|
+
result.descendants = [...new Set([...leftSet, ...rightSet])];
|
|
1105
|
+
break;
|
|
1106
|
+
case ECLNodeType.EXCLUSION:
|
|
1107
|
+
result.descendants = [...leftSet].filter(x => !rightSet.has(x));
|
|
1108
|
+
break;
|
|
1109
|
+
default:
|
|
1110
|
+
throw new Error(`Unknown compound operator: ${node.operator}`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async evaluateRefinedExpression(node) {
|
|
1117
|
+
// This is a simplified implementation
|
|
1118
|
+
// Full refinement evaluation would require analyzing concept relationships
|
|
1119
|
+
const baseFilter = await this.evaluateAST(node.base);
|
|
1120
|
+
|
|
1121
|
+
// For now, return the base filter
|
|
1122
|
+
// TODO: Implement refinement filtering based on node.refinement
|
|
1123
|
+
return baseFilter;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async evaluateMemberOf(node) {
|
|
1127
|
+
const refSetFilter = await this.evaluateAST(node.refSet);
|
|
1128
|
+
|
|
1129
|
+
if (refSetFilter.descendants && refSetFilter.descendants.length > 0) {
|
|
1130
|
+
const conceptIndex = refSetFilter.descendants[0];
|
|
1131
|
+
return this.sct.filterIn(this.sct.concepts.getConceptId(conceptIndex));
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
throw new Error('Invalid reference set for memberOf operation');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Convert filter context to user-friendly results
|
|
1139
|
+
*/
|
|
1140
|
+
convertFilterToResults(filterContext) {
|
|
1141
|
+
const results = [];
|
|
1142
|
+
|
|
1143
|
+
const concepts = filterContext.descendants || filterContext.matches || [];
|
|
1144
|
+
|
|
1145
|
+
for (const conceptIndex of concepts.slice(0, 1000)) { // Limit results
|
|
1146
|
+
try {
|
|
1147
|
+
let conceptId, term;
|
|
1148
|
+
|
|
1149
|
+
if (typeof conceptIndex === 'object' && conceptIndex.index !== undefined) {
|
|
1150
|
+
// From search results
|
|
1151
|
+
conceptId = conceptIndex.term;
|
|
1152
|
+
term = this.sct.getDisplayName(conceptIndex.index);
|
|
1153
|
+
} else {
|
|
1154
|
+
// From regular index
|
|
1155
|
+
conceptId = this.sct.concepts.getConceptId(conceptIndex);
|
|
1156
|
+
term = this.sct.getDisplayName(conceptIndex);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
results.push({
|
|
1160
|
+
conceptId: conceptId.toString(),
|
|
1161
|
+
term,
|
|
1162
|
+
active: this.sct.isActive(conceptIndex)
|
|
1163
|
+
});
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
// Skip concepts that can't be read
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return {
|
|
1171
|
+
total: concepts.length,
|
|
1172
|
+
results
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Helper to get concept reference index from concept ID
|
|
1178
|
+
*/
|
|
1179
|
+
getConceptReference(conceptId) {
|
|
1180
|
+
const id = this.sct.stringToId(conceptId);
|
|
1181
|
+
const result = this.sct.concepts.findConcept(id);
|
|
1182
|
+
if (!result.found) {
|
|
1183
|
+
throw new Error(`Concept ${conceptId} not found`);
|
|
1184
|
+
}
|
|
1185
|
+
return result.index;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Validate ECL syntax only (no semantic validation)
|
|
1190
|
+
*/
|
|
1191
|
+
validateSyntax(eclExpression) {
|
|
1192
|
+
try {
|
|
1193
|
+
const lexer = new ECLLexer(eclExpression);
|
|
1194
|
+
const tokens = lexer.tokenize();
|
|
1195
|
+
|
|
1196
|
+
const parser = new ECLParser(tokens);
|
|
1197
|
+
const ast = parser.parse();
|
|
1198
|
+
|
|
1199
|
+
return {
|
|
1200
|
+
success: true,
|
|
1201
|
+
ast,
|
|
1202
|
+
errors: []
|
|
1203
|
+
};
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
return {
|
|
1206
|
+
success: false,
|
|
1207
|
+
ast: null,
|
|
1208
|
+
errors: [error.message]
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Get examples of valid ECL expressions
|
|
1215
|
+
*/
|
|
1216
|
+
getExamples() {
|
|
1217
|
+
return [
|
|
1218
|
+
// Simple concept
|
|
1219
|
+
'73211009 |Diabetes mellitus|',
|
|
1220
|
+
|
|
1221
|
+
// Descendants
|
|
1222
|
+
'< 73211009 |Diabetes mellitus|',
|
|
1223
|
+
'<< 404684003 |Clinical finding|',
|
|
1224
|
+
|
|
1225
|
+
// Wildcards
|
|
1226
|
+
'*',
|
|
1227
|
+
|
|
1228
|
+
// Boolean operations
|
|
1229
|
+
'<< 19829001 |Disorder of lung| AND << 301867009 |Edema of trunk|',
|
|
1230
|
+
'<< 126537000 |Neoplasm of bone| OR << 92691004 |Secondary malignant neoplasm of bone|',
|
|
1231
|
+
|
|
1232
|
+
// Refinements
|
|
1233
|
+
'<< 404684003 |Clinical finding|: 116676008 |Associated morphology| = 72704001 |Fracture|',
|
|
1234
|
+
|
|
1235
|
+
// Attribute groups
|
|
1236
|
+
'<< 404684003 |Clinical finding|: {116676008 |Associated morphology| = 72704001 |Fracture|, 363698007 |Finding site| = << 71341001 |Bone structure|}',
|
|
1237
|
+
|
|
1238
|
+
// Member of reference set
|
|
1239
|
+
'^ 447562003 |ICD-10 complex map reference set|',
|
|
1240
|
+
|
|
1241
|
+
// Dotted attributes
|
|
1242
|
+
'<< 404684003 |Clinical finding|.116676008 |Associated morphology|'
|
|
1243
|
+
];
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
static CONCEPT_MODEL_ATTRIBUTE = '410662002'; // |Concept model attribute|
|
|
1247
|
+
static CLINICAL_FINDING = '404684003'; // |Clinical finding|
|
|
1248
|
+
static PROCEDURE = '71388002'; // |Procedure|
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Perform semantic validation on a parsed AST
|
|
1252
|
+
* This is separate from parse() and optional
|
|
1253
|
+
*/
|
|
1254
|
+
validateSemantics(ast) {
|
|
1255
|
+
const errors = [];
|
|
1256
|
+
this.validateSemanticAST(ast, errors);
|
|
1257
|
+
|
|
1258
|
+
return {
|
|
1259
|
+
success: errors.length === 0,
|
|
1260
|
+
errors
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Parse AND validate semantics in one call
|
|
1266
|
+
*/
|
|
1267
|
+
parseAndValidateSemantics(eclExpression) {
|
|
1268
|
+
const parseResult = this.parse(eclExpression);
|
|
1269
|
+
|
|
1270
|
+
if (!parseResult.success) {
|
|
1271
|
+
return parseResult;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const semanticResult = this.validateSemantics(parseResult.ast);
|
|
1275
|
+
|
|
1276
|
+
return {
|
|
1277
|
+
success: parseResult.success && semanticResult.success,
|
|
1278
|
+
ast: parseResult.ast,
|
|
1279
|
+
errors: [...parseResult.errors, ...semanticResult.errors]
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Semantic validation traversal (separate from basic validateAST)
|
|
1285
|
+
*/
|
|
1286
|
+
validateSemanticAST(node, errors) {
|
|
1287
|
+
if (!node || typeof node !== 'object') {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
switch (node.type) {
|
|
1292
|
+
case ECLNodeType.REFINED_EXPRESSION_CONSTRAINT:
|
|
1293
|
+
this.validateRefinedExpressionSemantics(node, errors);
|
|
1294
|
+
break;
|
|
1295
|
+
|
|
1296
|
+
case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT:
|
|
1297
|
+
this.validateSemanticAST(node.left, errors);
|
|
1298
|
+
this.validateSemanticAST(node.right, errors);
|
|
1299
|
+
break;
|
|
1300
|
+
|
|
1301
|
+
case ECLNodeType.DOTTED_EXPRESSION_CONSTRAINT:
|
|
1302
|
+
this.validateSemanticAST(node.base, errors);
|
|
1303
|
+
node.attributes.forEach(attr => this.validateSemanticAST(attr, errors));
|
|
1304
|
+
break;
|
|
1305
|
+
|
|
1306
|
+
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
|
|
1307
|
+
this.validateSemanticAST(node.focus, errors);
|
|
1308
|
+
break;
|
|
1309
|
+
|
|
1310
|
+
case ECLNodeType.MEMBER_OF:
|
|
1311
|
+
this.validateSemanticAST(node.refSet, errors);
|
|
1312
|
+
break;
|
|
1313
|
+
|
|
1314
|
+
case ECLNodeType.ATTRIBUTE_SET:
|
|
1315
|
+
node.attributes.forEach(attr => this.validateSemanticAST(attr, errors));
|
|
1316
|
+
break;
|
|
1317
|
+
|
|
1318
|
+
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
1319
|
+
node.attributes.forEach(attr => this.validateSemanticAST(attr, errors));
|
|
1320
|
+
break;
|
|
1321
|
+
|
|
1322
|
+
case ECLNodeType.ATTRIBUTE:
|
|
1323
|
+
this.validateAttributeSemantics(node, errors);
|
|
1324
|
+
break;
|
|
1325
|
+
|
|
1326
|
+
case ECLNodeType.EXPRESSION_COMPARISON:
|
|
1327
|
+
this.validateSemanticAST(node.value, errors);
|
|
1328
|
+
break;
|
|
1329
|
+
|
|
1330
|
+
// Basic nodes don't need semantic validation
|
|
1331
|
+
case ECLNodeType.CONCEPT_REFERENCE:
|
|
1332
|
+
case ECLNodeType.WILDCARD:
|
|
1333
|
+
break;
|
|
1334
|
+
|
|
1335
|
+
default:
|
|
1336
|
+
// Allow unknown node types to pass through
|
|
1337
|
+
break;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Validate semantics of refined expressions
|
|
1343
|
+
*/
|
|
1344
|
+
validateRefinedExpressionSemantics(node, errors) {
|
|
1345
|
+
this.validateSemanticAST(node.base, errors);
|
|
1346
|
+
this.validateSemanticAST(node.refinement, errors);
|
|
1347
|
+
|
|
1348
|
+
// Get the base concept(s) being refined
|
|
1349
|
+
const baseConcepts = this.extractBaseConceptIds(node.base);
|
|
1350
|
+
const attributes = this.extractAttributesFromRefinement(node.refinement);
|
|
1351
|
+
|
|
1352
|
+
// Validate each attribute in context
|
|
1353
|
+
for (const attr of attributes) {
|
|
1354
|
+
this.validateAttributeInContext(attr, baseConcepts, errors);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Validate attribute semantics
|
|
1360
|
+
*/
|
|
1361
|
+
validateAttributeSemantics(node, errors) {
|
|
1362
|
+
// Validate the attribute name is a relationship type
|
|
1363
|
+
this.validateRelationshipType(node.name, errors);
|
|
1364
|
+
|
|
1365
|
+
// Validate the comparison if present
|
|
1366
|
+
if (node.comparison && node.comparison.value) {
|
|
1367
|
+
this.validateSemanticAST(node.comparison.value, errors);
|
|
1368
|
+
this.validateAttributeRange(node.name, node.comparison.value, errors);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Check if concept is a valid relationship type
|
|
1374
|
+
*/
|
|
1375
|
+
validateRelationshipType(attributeNode, errors) {
|
|
1376
|
+
if (attributeNode.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
1377
|
+
return; // Skip validation for wildcards or complex expressions
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
try {
|
|
1381
|
+
const conceptId = attributeNode.conceptId;
|
|
1382
|
+
const conceptIndex = this.getConceptReference(conceptId);
|
|
1383
|
+
|
|
1384
|
+
// Check if it's a descendant of "Concept model attribute"
|
|
1385
|
+
const attributeRootIndex = this.getConceptReference(ECLValidator.CONCEPT_MODEL_ATTRIBUTE);
|
|
1386
|
+
|
|
1387
|
+
if (!this.sct.subsumes(attributeRootIndex, conceptIndex)) {
|
|
1388
|
+
const displayName = attributeNode.term || this.sct.getDisplayName(conceptIndex);
|
|
1389
|
+
errors.push(`Concept ${conceptId} |${displayName}| is not a valid relationship type. Relationship types must be descendants of ${ECLValidator.CONCEPT_MODEL_ATTRIBUTE} |Concept model attribute|`);
|
|
1390
|
+
}
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
errors.push(`Error validating relationship type ${attributeNode.conceptId}: ${error.message}`);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Validate attribute usage in context of base concepts
|
|
1398
|
+
*/
|
|
1399
|
+
validateAttributeInContext(attribute, baseConcepts, errors) {
|
|
1400
|
+
// Check domain appropriateness
|
|
1401
|
+
for (const baseConcept of baseConcepts) {
|
|
1402
|
+
this.validateAttributeDomain(attribute.name, baseConcept, errors);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Validate attribute domain (which concepts can use this attribute)
|
|
1408
|
+
*/
|
|
1409
|
+
validateAttributeDomain(attributeNode, baseConceptIndex, errors) {
|
|
1410
|
+
if (attributeNode.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
try {
|
|
1415
|
+
const attributeId = attributeNode.conceptId;
|
|
1416
|
+
|
|
1417
|
+
// Define common domain restrictions
|
|
1418
|
+
const domainRules = {
|
|
1419
|
+
'116676008': ['404684003'], // |Associated morphology| -> |Clinical finding|
|
|
1420
|
+
'363698007': ['404684003'], // |Finding site| -> |Clinical finding|
|
|
1421
|
+
'42752001': ['71388002'], // |Due to| -> |Procedure|
|
|
1422
|
+
'260686004': ['71388002'], // |Method| -> |Procedure|
|
|
1423
|
+
'405815000': ['71388002'] // |Procedure device| -> |Procedure|
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
if (domainRules[attributeId]) {
|
|
1427
|
+
const allowedHierarchies = domainRules[attributeId];
|
|
1428
|
+
const isValidDomain = allowedHierarchies.some(hierarchyId => {
|
|
1429
|
+
const hierarchyIndex = this.getConceptReference(hierarchyId);
|
|
1430
|
+
return this.sct.subsumes(hierarchyIndex, baseConceptIndex);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
if (!isValidDomain) {
|
|
1434
|
+
const hierarchyNames = allowedHierarchies.map(id => {
|
|
1435
|
+
const idx = this.getConceptReference(id);
|
|
1436
|
+
return this.sct.getDisplayName(idx);
|
|
1437
|
+
}).join(', ');
|
|
1438
|
+
|
|
1439
|
+
const displayName = attributeNode.term || this.sct.getDisplayName(this.getConceptReference(attributeId));
|
|
1440
|
+
errors.push(`Attribute ${attributeId} |${displayName}| is not typically used with concepts outside of: ${hierarchyNames}`);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
errors.push(`Error validating attribute domain for ${attributeNode.conceptId}: ${error.message}`);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Validate attribute range (what values are allowed)
|
|
1450
|
+
*/
|
|
1451
|
+
validateAttributeRange(attributeNode, valueNode, errors) {
|
|
1452
|
+
if (attributeNode.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
try {
|
|
1457
|
+
// Extract the actual concept reference from the value node
|
|
1458
|
+
let actualValueNode = valueNode;
|
|
1459
|
+
if (valueNode.type === ECLNodeType.SUB_EXPRESSION_CONSTRAINT) {
|
|
1460
|
+
actualValueNode = valueNode.focus;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (actualValueNode.type !== ECLNodeType.CONCEPT_REFERENCE) {
|
|
1464
|
+
return; // Skip validation for wildcards or complex expressions
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const attributeId = attributeNode.conceptId;
|
|
1468
|
+
const valueId = actualValueNode.conceptId;
|
|
1469
|
+
|
|
1470
|
+
// Define common range restrictions
|
|
1471
|
+
const rangeRules = {
|
|
1472
|
+
'116676008': ['49755003'], // |Associated morphology| -> |Morphologically abnormal structure|
|
|
1473
|
+
'363698007': ['442083009'], // |Finding site| -> |Anatomical or acquired body structure|
|
|
1474
|
+
'42752001': ['404684003'], // |Due to| -> |Clinical finding|
|
|
1475
|
+
'47429007': ['78621006'] // |Associated with| -> |Physical force|
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
if (rangeRules[attributeId]) {
|
|
1479
|
+
const allowedRanges = rangeRules[attributeId];
|
|
1480
|
+
const valueIndex = this.getConceptReference(valueId);
|
|
1481
|
+
|
|
1482
|
+
const isValidRange = allowedRanges.some(rangeId => {
|
|
1483
|
+
const rangeIndex = this.getConceptReference(rangeId);
|
|
1484
|
+
return this.sct.subsumes(rangeIndex, valueIndex);
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
if (!isValidRange) {
|
|
1488
|
+
const rangeNames = allowedRanges.map(id => {
|
|
1489
|
+
const idx = this.getConceptReference(id);
|
|
1490
|
+
return this.sct.getDisplayName(idx);
|
|
1491
|
+
}).join(', ');
|
|
1492
|
+
|
|
1493
|
+
const valueDisplayName = actualValueNode.term || this.sct.getDisplayName(valueIndex);
|
|
1494
|
+
errors.push(`Value ${valueId} |${valueDisplayName}| is not a valid concept for attribute ${attributeId}. Expected values from: ${rangeNames}`);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
errors.push(`Error validating attribute range for ${attributeNode.conceptId}: ${error.message}`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Extract base concept IDs from expression constraint
|
|
1504
|
+
*/
|
|
1505
|
+
extractBaseConceptIds(node) {
|
|
1506
|
+
const concepts = [];
|
|
1507
|
+
|
|
1508
|
+
switch (node.type) {
|
|
1509
|
+
case ECLNodeType.SUB_EXPRESSION_CONSTRAINT:
|
|
1510
|
+
concepts.push(...this.extractBaseConceptIds(node.focus));
|
|
1511
|
+
break;
|
|
1512
|
+
case ECLNodeType.CONCEPT_REFERENCE:
|
|
1513
|
+
concepts.push(this.getConceptReference(node.conceptId));
|
|
1514
|
+
break;
|
|
1515
|
+
case ECLNodeType.COMPOUND_EXPRESSION_CONSTRAINT:
|
|
1516
|
+
concepts.push(...this.extractBaseConceptIds(node.left));
|
|
1517
|
+
concepts.push(...this.extractBaseConceptIds(node.right));
|
|
1518
|
+
break;
|
|
1519
|
+
case ECLNodeType.MEMBER_OF:
|
|
1520
|
+
concepts.push(...this.extractBaseConceptIds(node.refSet));
|
|
1521
|
+
break;
|
|
1522
|
+
case ECLNodeType.WILDCARD:
|
|
1523
|
+
// Wildcards represent all concepts - skip semantic validation
|
|
1524
|
+
break;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return concepts;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Extract attributes from refinement
|
|
1532
|
+
*/
|
|
1533
|
+
extractAttributesFromRefinement(refinementNode) {
|
|
1534
|
+
const attributes = [];
|
|
1535
|
+
|
|
1536
|
+
switch (refinementNode.type) {
|
|
1537
|
+
case ECLNodeType.ATTRIBUTE:
|
|
1538
|
+
attributes.push(refinementNode);
|
|
1539
|
+
break;
|
|
1540
|
+
case ECLNodeType.ATTRIBUTE_SET:
|
|
1541
|
+
attributes.push(...refinementNode.attributes);
|
|
1542
|
+
break;
|
|
1543
|
+
case ECLNodeType.ATTRIBUTE_GROUP:
|
|
1544
|
+
attributes.push(...refinementNode.attributes);
|
|
1545
|
+
break;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
return attributes;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
module.exports = {
|
|
1555
|
+
ECLValidator,
|
|
1556
|
+
ECLLexer,
|
|
1557
|
+
ECLParser,
|
|
1558
|
+
ECLTokenType,
|
|
1559
|
+
ECLNodeType
|
|
1560
|
+
};
|