Skip to content

Commit eca841d

Browse files
Merge branch 'bugfix/LF-3522/LF-3533/incorrect-string-normalization-and-unicode-escape-parsing' into 'master'
Fix incorrect string normalization and Unicode escape parsing See merge request lfor/fhirpath.js!60
2 parents 4cab729 + 2f898f9 commit eca841d

12 files changed

Lines changed: 184 additions & 33 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
## Testing workflow (project-specific)
2121
- Spec-style unit tests are YAML-driven: `test/fhirpath.test.js` loads `test/cases/*.yaml` and generates Jest tests dynamically.
22+
- `test/cases/fhir-r4.yaml` and `test/cases/fhir-r5.yaml` are generated from `converter/dataset`; refresh them with `node converter/bin/index.js -s` (skip download, use local dataset files).
2223
- Additional Jest suites in `test/*.test.js` cover APIs and behaviors outside YAML cases (e.g. `test/async-functions.test.js`, `test/user-invocation-table.test.js`, `test/bin_fhirpath.test.js`).
2324
- Many tests run in both math modes automatically (`preciseMath` true/false) unless a case sets `preciseMath` explicitly.
2425
- `npm run test:unit` runs Jest three times across time zones (`default`, `America/New_York`, `Europe/Paris`) to catch datetime regressions.
@@ -32,6 +33,7 @@
3233
- Lint: `npm run lint` (targets `src/parser/index.js`, `src/*.js`, `converter/`).
3334
- Unit tests: `npm run test:unit`; debugger mode: `npm run test:unit:debug`.
3435
- Type tests: `npm run test:tsd`.
36+
- Demo build: `npm run build:demo` (`npm run build` + `demo` webpack build).
3537
- E2E tests: `npm run test:e2e` (builds demo + runs Cypress).
3638
- Full CI-like local run: `npm test` (lint + tsd + unit + e2e).
3739
- Parser regeneration: `npm run generateParser` (uses `src/parser/FHIRPath.g4`, `antlr-4.9.3-complete.jar`, then `scripts/fix-parser-imports.js`).
@@ -55,3 +57,4 @@
5557
- For async server calls, thread options via `terminologyUrl`, `fhirServerUrl`, `httpHeaders`, and optional `signal` (see `docs/auth.md`, `docs/abort.md`).
5658
- If public API signatures or exports change, update `src/fhirpath.d.ts` and validate with `npm run test:tsd` (`test/typescript/fhirpath.test-d.ts`).
5759
- Keep behavior standards-compliant with FHIRPath and aligned with the selected FHIR model version when model-aware behavior is involved.
60+
- Contributors **MUST NOT** hand-edit `test/cases/fhir-r4.yaml` or `test/cases/fhir-r5.yaml` (these files are generated).

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@
33
This log documents significant changes for each release. This project follows
44
[Semantic Versioning](http://semver.org/).
55

6-
## [4.9.3] - 2026-03-27
6+
## [4.9.3] - 2026-03-31
77
### Changed
8+
- Updated package repository metadata in `package.json` to an explicit git
9+
repository object.
810
- `npm run compare-performance` can now use a local git ref as the baseline,
911
keeps benchmark labels explicit about previous/current results, and includes a
1012
dedicated FHIR quantity context benchmark.
1113
### Fixed
14+
- String equivalence and non-equivalence normalization now collapses repeated
15+
whitespace globally, so expressions like `'ab c d' ~ 'Ab C D'` evaluate as
16+
equivalent.
17+
- Unicode escape parsing in literals now accepts full hex digits for `\uXXXX`
18+
(for example, `\u00e9` and `\u00E9`) in both single-quoted strings and
19+
backtick-delimited identifiers.
1220
- FHIR `Quantity` duration values sourced from resources now preserve their
1321
original UCUM code in string output and quantity math while treated as
1422
corresponding calendar durations in date/time arithmetic.

demo/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 14 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
"scripts",
7373
"src"
7474
],
75-
"repository": "github:HL7/fhirpath.js",
75+
"repository": {
76+
"type": "git",
77+
"url": "git+https://github.com/HL7/fhirpath.js.git"
78+
},
7679
"license": "SEE LICENSE in LICENSE.md"
7780
}

src/deep-equal.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ const isArguments = function (object) {
2020

2121
/**
2222
* Normalizes a string for equivalence comparison by converting to uppercase
23-
* and collapsing whitespace sequences to a single space.
23+
* and replacing every whitespace character with a regular ASCII space.
2424
* @param {string} x - the string to normalize.
2525
* @returns {string} the normalized string.
2626
*/
2727
function normalizeStr(x) {
28-
return x.toUpperCase().replace(/\s+/, ' ');
28+
return x.toUpperCase().replace(/\p{White_Space}/ug, ' ');
2929
}
3030

3131

@@ -61,10 +61,10 @@ function deepEqual(ctx, v1, v2, opts) {
6161
const typeOfExpected = typeof expected;
6262

6363
if (opts.fuzzy) {
64-
if(typeOfActual === 'string' && typeOfExpected === 'string') {
64+
if (typeOfActual === 'string' && typeOfExpected === 'string') {
6565
return normalizeStr(actual) === normalizeStr(expected);
6666
}
67-
if(typeOfActual === 'number' && typeOfExpected === 'number') {
67+
if (typeOfActual === 'number' && typeOfExpected === 'number') {
6868
return numbers.isEquivalent(actual, expected);
6969
}
7070
}
@@ -81,6 +81,7 @@ function deepEqual(ctx, v1, v2, opts) {
8181
// deepEqual(ctx, v1._data, v2._data, opts) : true);
8282
// If this ever becomes possible, the above code should be used instead
8383
// of the code below.
84+
// noinspection EqualityComparisonWithCoercionJS
8485
return actual == expected;
8586
} else if (typeOfExpected === 'number') {
8687
// Note: currently, a resource node with a direct number value is not
@@ -104,6 +105,7 @@ function deepEqual(ctx, v1, v2, opts) {
104105
// deepEqual(ctx, v1._data, v2._data, opts) : true);
105106
// If this ever becomes possible, the above code should be used instead
106107
// of the code below.
108+
// noinspection EqualityComparisonWithCoercionJS
107109
return actual == expected;
108110
}
109111
}
@@ -190,7 +192,7 @@ function objEquiv(ctx, a, b, opts) {
190192
if (a.prototype !== b.prototype) return false;
191193
//~~~I've managed to break Object.keys through screwy arguments passing.
192194
// Converting to array solves the problem.
193-
if(isArguments(a) || isArguments(b)) {
195+
if (isArguments(a) || isArguments(b)) {
194196
a = isArguments(a) ? pSlice.call(a) : a;
195197
b = isArguments(b) ? pSlice.call(b) : b;
196198
return deepEqual(ctx, a, b, opts);

src/fhirpath.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ function getIdentifierVal(str) {
450450
* @return {string}
451451
*/
452452
function handleStringEscapes(str) {
453-
return str.replace(/\\(u\d{4}|.)/g, function(match, submatch) {
453+
return str.replace(/\\(u[0-9a-fA-F]{4}|.)/g, function(match, submatch) {
454454
switch (match) {
455455
case '\\r':
456456
return '\r';
@@ -462,7 +462,7 @@ function handleStringEscapes(str) {
462462
return '\f';
463463
default:
464464
if (submatch.length > 1) {
465-
return String.fromCharCode('0x' + submatch.slice(1));
465+
return String.fromCharCode(parseInt(submatch.slice(1), 16));
466466
}
467467
return submatch;
468468
}

test/benchmark.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const availableTests = [
2828
'addition',
2929
'gln-validation-expression',
3030
'comparison',
31+
'equivalence',
3132
'contains',
3233
'descendants',
3334
'distinct',

test/benchmark/comparison.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,28 @@
1-
const _ = require('lodash');
2-
31
module.exports = ({
42
options
53
}) => {
64

7-
const expression = "1 < 2";
5+
const simpleExpression = "1 < 2";
86

97
return [
108
...(options.compileOnly
119
? []
1210
: [{
1311
name: `Do simple comparison 1 < 2 using evaluate()`,
1412
filename: 'comparison-evaluate',
15-
expression,
13+
expression: simpleExpression,
1614
cases: [{
1715
name: '',
1816
testFunction: (fhirpath, model) => {
19-
return () => fhirpath.evaluate({}, expression, {}, model);
17+
return () => fhirpath.evaluate({}, simpleExpression, {}, model);
2018
}
2119
}
2220
]
2321
}]),
2422
{
2523
name: `Do simple comparison 1 < 2 using compile()`,
2624
filename: 'comparison-compile',
27-
expression,
25+
expression: simpleExpression,
2826
cases: [
2927
{
3028
name: '',

test/benchmark/equivalence.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
module.exports = ({
2+
options
3+
}) => {
4+
5+
const whitespaceEquivalenceExpression = "'ab\tc\td' ~ 'Ab C D'";
6+
7+
return [
8+
...(options.compileOnly
9+
? []
10+
: [{
11+
name: 'Equivalence with repeated whitespace using evaluate()',
12+
filename: 'equivalence-evaluate',
13+
expression: whitespaceEquivalenceExpression,
14+
cases: [{
15+
name: '',
16+
testFunction: (fhirpath, model) => {
17+
return () => fhirpath.evaluate({}, whitespaceEquivalenceExpression, {}, model);
18+
}
19+
}]
20+
}]),
21+
{
22+
name: 'Equivalence with repeated whitespace using compile()',
23+
filename: 'equivalence-compile',
24+
expression: whitespaceEquivalenceExpression,
25+
cases: [{
26+
name: '',
27+
testFunction: (fhirpath, model, compiledFn) => {
28+
return () => compiledFn({}, {});
29+
}
30+
}]
31+
}];
32+
33+
}
34+

0 commit comments

Comments
 (0)