Skip to content

Commit 9cafb9e

Browse files
committed
Implement escape and unescape
1 parent 8785553 commit 9cafb9e

5 files changed

Lines changed: 239 additions & 3 deletions

File tree

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,6 @@ documented in the table below.
267267
| `testQuantity4` | Test | | [PR](https://github.com/FHIR/fhir-test-cases/pull/243) | |
268268
| `testSubSetOf3` | Specification/Test | | | The test resource is invalid and missing (https://github.com/FHIR/fhir-test-cases/issues/247); the scope of "$this" is unclear (https://jira.hl7.org/browse/FHIR-44601) |
269269
| `testIif11` | Implementation | | | https://jira.hl7.org/browse/FHIR-44774; https://jira.hl7.org/browse/FHIR-44601 |
270-
| `testEscape*` | Implementation | STU | | Function `escape` is not implemented. |
271-
| `testUnescape*` | Implementation | STU | | Function `unescape` is not implemented. |
272270
| `testNow1` | Specification/Test | | | As `testDateTimeGreaterThanDate1`. |
273271
| `testSort8` | Specification/Test | | | Test uses `-$this` for descending string sort, but spec uses `asc`/`desc`, https://github.com/FHIR/fhir-test-cases/issues/253. |
274272
| `testSort10` | Specification/Test | | | Test uses `-` prefix for descending sort, but spec uses `asc`/`desc`, https://github.com/FHIR/fhir-test-cases/issues/253. |

fhir-path-core/src/commonMain/kotlin/dev/ohs/fhir/fhirpath/functions/FirstOrderFunctions.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ internal fun Collection<Any>.invoke(
108108
// https://build.fhir.org/ig/HL7/FHIRPath/#additional-string-functions
109109
"encode" -> this.encode(params, fhirPathTypeResolver)
110110
"decode" -> this.decode(params, fhirPathTypeResolver)
111+
"escape" -> this.escape(params, fhirPathTypeResolver)
112+
"unescape" -> this.unescape(params, fhirPathTypeResolver)
111113
"trim" -> this.trim(fhirPathTypeResolver)
112114
"split" -> this.split(params, fhirPathTypeResolver)
113115
"join" -> this.join(params, fhirPathTypeResolver)

fhir-path-core/src/commonMain/kotlin/dev/ohs/fhir/fhirpath/functions/Strings.kt

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,161 @@ private fun isUnreservedUrlChar(ch: Char): Boolean =
360360
ch == '_' ||
361361
ch == '.' ||
362362
ch == '~'
363+
364+
/**
365+
* Escapes the single string item in the input collection for the specified target. Supported
366+
* targets: `'html'` and `'json'`.
367+
*
368+
* See [specification](https://build.fhir.org/ig/HL7/FHIRPath/#escapetarget--string--string).
369+
*/
370+
internal fun Collection<Any>.escape(
371+
params: List<Any>,
372+
fhirPathTypeResolver: FhirPathTypeResolver,
373+
): Collection<String> {
374+
check(size <= 1) { "escape() cannot be called on a collection with more than 1 item" }
375+
val input = singleOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()
376+
val target = params.firstOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()
377+
378+
return when (target.lowercase()) {
379+
"html" -> listOf(htmlEscape(input))
380+
"json" -> listOf(jsonEscape(input))
381+
else -> emptyList()
382+
}
383+
}
384+
385+
/**
386+
* Unescapes the single string item in the input collection for the specified target. Supported
387+
* targets: `'html'` and `'json'`.
388+
*
389+
* See [specification](https://build.fhir.org/ig/HL7/FHIRPath/#unescapetarget--string--string).
390+
*/
391+
internal fun Collection<Any>.unescape(
392+
params: List<Any>,
393+
fhirPathTypeResolver: FhirPathTypeResolver,
394+
): Collection<String> {
395+
check(size <= 1) { "unescape() cannot be called on a collection with more than 1 item" }
396+
val input = singleOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()
397+
val target = params.firstOrNull()?.unwrapString(fhirPathTypeResolver) ?: return emptyList()
398+
399+
val result =
400+
try {
401+
when (target.lowercase()) {
402+
"html" -> htmlUnescape(input)
403+
"json" -> jsonUnescape(input)
404+
else -> return emptyList()
405+
}
406+
} catch (_: Exception) {
407+
return emptyList()
408+
}
409+
410+
return listOf(result)
411+
}
412+
413+
/**
414+
* Escapes the HTML special characters `&`, `<`, `>`, `"` and `'` as character entities.
415+
*
416+
* The specification requires at least `<`, `&` and quotes, and says characters above 127 are
417+
* "ideally" escaped as well; this implementation deliberately keeps non-ASCII characters literal,
418+
* since FHIR content is UTF-8 throughout.
419+
*/
420+
private fun htmlEscape(input: String): String = buildString {
421+
for (ch in input) {
422+
when (ch) {
423+
'&' -> append("&amp;")
424+
'<' -> append("&lt;")
425+
'>' -> append("&gt;")
426+
'"' -> append("&quot;")
427+
'\'' -> append("&#39;")
428+
else -> append(ch)
429+
}
430+
}
431+
}
432+
433+
/** Decodes HTML character entities, both named (`&amp;` etc.) and numeric (`&#65;`, `&#x41;`). */
434+
private fun htmlUnescape(input: String): String = buildString {
435+
var i = 0
436+
while (i < input.length) {
437+
val ch = input[i]
438+
// The scan for `;` is capped so a long run of bare ampersands stays linear; the longest
439+
// recognized entity is far shorter than the cap.
440+
val end = if (ch == '&') input.indexOf(';', i).takeIf { it in i..(i + 12) } ?: -1 else -1
441+
if (end > i) {
442+
val entity = input.substring(i + 1, end)
443+
val decoded =
444+
when {
445+
entity == "amp" -> "&"
446+
entity == "lt" -> "<"
447+
entity == "gt" -> ">"
448+
entity == "quot" -> "\""
449+
entity == "apos" -> "'"
450+
entity.startsWith("#x") || entity.startsWith("#X") ->
451+
codePointToString(entity.drop(2).toInt(16))
452+
entity.startsWith("#") -> codePointToString(entity.drop(1).toInt())
453+
else -> null
454+
}
455+
if (decoded != null) {
456+
append(decoded)
457+
i = end + 1
458+
continue
459+
}
460+
}
461+
append(ch)
462+
i++
463+
}
464+
}
465+
466+
/** Escapes `\`, `"` and control characters as in a JSON string literal. */
467+
private fun jsonEscape(input: String): String = buildString {
468+
for (ch in input) {
469+
when {
470+
ch == '\\' -> append("\\\\")
471+
ch == '"' -> append("\\\"")
472+
ch == '\n' -> append("\\n")
473+
ch == '\r' -> append("\\r")
474+
ch == '\t' -> append("\\t")
475+
ch == '\b' -> append("\\b")
476+
ch == '\u000C' -> append("\\f")
477+
ch < ' ' -> append("\\u" + ch.code.toString(16).padStart(4, '0'))
478+
else -> append(ch)
479+
}
480+
}
481+
}
482+
483+
/** Decodes JSON string literal escape sequences, including `\uXXXX`. */
484+
private fun jsonUnescape(input: String): String = buildString {
485+
var i = 0
486+
while (i < input.length) {
487+
val ch = input[i]
488+
if (ch == '\\' && i + 1 < input.length) {
489+
when (val next = input[i + 1]) {
490+
'"' -> append('"')
491+
'\\' -> append('\\')
492+
'/' -> append('/')
493+
'n' -> append('\n')
494+
'r' -> append('\r')
495+
't' -> append('\t')
496+
'b' -> append('\b')
497+
'f' -> append('\u000C')
498+
'u' -> {
499+
append(codePointToString(input.substring(i + 2, i + 6).toInt(16)))
500+
i += 6
501+
continue
502+
}
503+
else -> append(next)
504+
}
505+
i += 2
506+
} else {
507+
append(ch)
508+
i++
509+
}
510+
}
511+
}
512+
513+
/** Converts a Unicode code point to a string, using a surrogate pair above the BMP. */
514+
private fun codePointToString(codePoint: Int): String =
515+
if (codePoint <= 0xFFFF) {
516+
Char(codePoint).toString()
517+
} else {
518+
val offset = codePoint - 0x10000
519+
charArrayOf(Char(0xD800 + (offset shr 10)), Char(0xDC00 + (offset and 0x3FF))).concatToString()
520+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2026 Open Health Stack Foundation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package dev.ohs.fhir.fhirpath
18+
19+
import kotlin.test.Test
20+
import kotlin.test.assertEquals
21+
22+
private val fhirPathEngine = FhirPathEngine.forR4()
23+
24+
class EscapeUnescapeTest {
25+
26+
@Test
27+
fun `escape and unescape html`() {
28+
val escaped = fhirPathEngine.evaluateExpression("'a & b < c'.escape('html')", null)
29+
assertEquals(listOf("a &amp; b &lt; c"), escaped.toList())
30+
31+
val unescaped = fhirPathEngine.evaluateExpression("'a &amp; b &lt; c'.unescape('html')", null)
32+
assertEquals(listOf("a & b < c"), unescaped.toList())
33+
}
34+
35+
@Test
36+
fun `unescape numeric html entities`() {
37+
val unescaped = fhirPathEngine.evaluateExpression("'&#65;&#x42;'.unescape('html')", null)
38+
assertEquals(listOf("AB"), unescaped.toList())
39+
}
40+
41+
@Test
42+
fun `escape and unescape json`() {
43+
val escaped = fhirPathEngine.evaluateExpression("'say \\'hi\\''.escape('json')", null)
44+
assertEquals(listOf("say 'hi'"), escaped.toList())
45+
46+
val unescaped = fhirPathEngine.evaluateExpression("'a\\\\u0041'.unescape('json')", null)
47+
assertEquals(listOf("aA"), unescaped.toList())
48+
}
49+
50+
@Test
51+
fun `html escape keeps non-ascii characters literal`() {
52+
// The spec only requires `<`, `&` and quotes; characters above 127 deliberately stay literal.
53+
val escaped = fhirPathEngine.evaluateExpression("'café < 1€'.escape('html')", null)
54+
assertEquals(listOf("café &lt; 1€"), escaped.toList())
55+
}
56+
57+
@Test
58+
fun `json escape uses shorthand for control characters`() {
59+
// `\\b` in the FHIRPath literal is a literal backslash and `b`; `\b` alone would be consumed
60+
// by the FHIRPath string escape handling before unescape() runs.
61+
val unescaped = fhirPathEngine.evaluateExpression("'a\\\\bb\\\\ff'.unescape('json')", null)
62+
val reEscaped =
63+
fhirPathEngine.evaluateExpression("'a\\\\bb\\\\ff'.unescape('json').escape('json')", null)
64+
assertEquals(listOf("a\bb\u000Cf"), unescaped.toList())
65+
assertEquals(listOf("a\\bb\\ff"), reEscaped.toList())
66+
}
67+
68+
@Test
69+
fun `unknown target returns empty`() {
70+
assertEquals(
71+
emptyList(),
72+
fhirPathEngine.evaluateExpression("'test'.escape('xml1')", null).toList(),
73+
)
74+
assertEquals(
75+
emptyList(),
76+
fhirPathEngine.evaluateExpression("'test'.unescape('xml1')", null).toList(),
77+
)
78+
}
79+
}

fhir-path/src/commonTest/kotlin/dev/ohs/fhir/fhirpath/FhirPathEngineTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ private val fhirPathEngine = FhirPathEngine.forR4()
4444
*/
4545
val skippedTestGroupToReasonMap =
4646
mapOf(
47-
"testEscapeUnescape" to "Unimplemented",
4847
"testVariables" to "Unimplemented",
4948
"testConformsTo" to "Unimplemented",
5049
"Comparable" to "Unimplemented",

0 commit comments

Comments
 (0)