@@ -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(" &" )
424+ ' <' -> append(" <" )
425+ ' >' -> append(" >" )
426+ ' "' -> append(" "" )
427+ ' \' ' -> append(" '" )
428+ else -> append(ch)
429+ }
430+ }
431+ }
432+
433+ /* * Decodes HTML character entities, both named (`&` etc.) and numeric (`A`, `A`). */
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+ }
0 commit comments