From fbd410c7d75fb67e02284a4ab809c7c74a699419 Mon Sep 17 00:00:00 2001 From: jstet Date: Wed, 4 Nov 2026 20:58:02 +0100 Subject: [PATCH] fix(lstsv): emit regex() as regexMatch('/pat/', x), pattern first and delimited LimeSurvey's regexMatch(pattern, input) needs a delimited pattern (PHP preg_match, JS RegExp). regex(x, 'pat') now becomes regexMatch('/^(?:pat)$/', x): `/` is escaped, and the pattern is anchored because ODK matches the whole value while LimeSurvey searches. The reverse reads this back to regex(x, 'pat') and still accepts the old field-first form. The bare-regexMatch escape hatch delimits its pattern too. Fixes #218. Co-Authored-By: Claude Opus 5.5 --- registry/conventions/logicMapping.jsonld | 3 +- src/conventions/regexPattern.ts | 65 +++++++++++++++++++ src/generated/conventions.json | 3 +- src/generated/conventions.ts | 3 +- src/instrument/reverseExpressions.ts | 33 +++++++--- src/pipelines/lstsv2xlsform/README.md | 15 ++--- .../xlsform2lstsv/xpathTranspiler.ts | 27 ++++++-- .../surveys/complex_survey/xlsform.json | 2 +- .../surveys/complex_xpath_survey/xlsform.json | 2 +- .../surveys/multilingual_survey/xlsform.json | 2 +- .../surveys/multipage_survey/xlsform.json | 27 ++++++-- tests/fixtures/surveys/testB/tsv.tsv | 4 +- .../limesurvey/answers/select_multiple.json | 5 +- .../answers/select_multiple_long_list.json | 6 +- tests/live/limesurvey/docker-compose.yml | 8 +-- tests/ts/unit/expressions/ast.test.ts | 2 +- .../xpathConstraintConverter.test.ts | 16 ++++- .../unit/expressions/xpathFunctions.test.ts | 41 +++++++++++- .../lstsv2xlsform/knownLosses.test.ts | 24 +++---- .../lstsv2xlsform/reverseExpressions.test.ts | 28 ++++++++ 20 files changed, 249 insertions(+), 67 deletions(-) create mode 100644 src/conventions/regexPattern.ts diff --git a/registry/conventions/logicMapping.jsonld b/registry/conventions/logicMapping.jsonld index c82547c..290be15 100644 --- a/registry/conventions/logicMapping.jsonld +++ b/registry/conventions/logicMapping.jsonld @@ -94,7 +94,8 @@ }, { "xlsform": "regex(., '...')", - "ls": "regexMatch(...)" + "ls": "regexMatch('/^(?:...)$/', self)", + "note": "LimeSurvey takes the pattern first and delimited (`/…/`, `/` escaped), and searches instead of matching the whole value as ODK does, so the pattern is wrapped in `^(?:…)$` (left alone when it is already anchored). lstsv2xlsform strips both and also reads the old field-first `regexMatch(x, 'pat')`." }, { "xlsform": "coalesce(x, y)", diff --git a/src/conventions/regexPattern.ts b/src/conventions/regexPattern.ts new file mode 100644 index 0000000..bbee2b6 --- /dev/null +++ b/src/conventions/regexPattern.ts @@ -0,0 +1,65 @@ +/** + * Regex pattern conversion between ODK `regex()` and LimeSurvey's + * `regexMatch(pattern, input)` (convention:logicMapping syntaxMappings). + * + * ODK matches the whole string; LimeSurvey's PHP `preg_match` and JS + * `RegExp.test` search anywhere in it, and need a delimited pattern + * (`/pat/`). Forward therefore anchors (`^(?:pat)$`, skipped when `pat` + * already is) and escapes `/`; reverse undoes exactly that. + */ + +/** Does `p` start with `^` and end with an unescaped `$`, with no `|`? */ +function isAnchored(p: string): boolean { + if (!p.startsWith('^') || !p.endsWith('$') || p.includes('|')) return false; + const slashes = /\\*\$$/.exec(p)?.[0].length ?? 1; + return (slashes - 1) % 2 === 0; +} + +/** Escape every `/` not already escaped. */ +function escapeSlashes(p: string): string { + let out = ''; + for (let i = 0; i < p.length; i++) { + if (p[i] === '\\' && i + 1 < p.length) out += p[i] + p[++i]; + else out += p[i] === '/' ? '\\/' : p[i]; + } + return out; +} + +/** The body of an ODK regex as the text LimeSurvey gets (delimiters included). */ +export function odkPatternToLs(pattern: string): string { + const body = isAnchored(pattern) ? pattern : `^(?:${pattern})$`; + return `/${escapeSlashes(body)}/`; +} + +/** Already `/…/flags`? (EM-flavoured source written by the author.) */ +export function isDelimited(pattern: string): boolean { + return /^\/.*\/[a-z]*$/s.test(pattern); +} + +/** Delimit a pattern without changing its meaning (no anchoring). */ +export function delimitPattern(pattern: string): string { + return isDelimited(pattern) ? pattern : `/${escapeSlashes(pattern)}/`; +} + +/** + * LimeSurvey `/pat/` → the ODK pattern. Flags other than none cannot be + * expressed in ODK and are kept in the delimiter form (returns null). + */ +export function lsPatternToOdk(pattern: string): string | null { + const m = /^\/(.*)\/$/s.exec(pattern); + if (!m) return null; + const body = m[1].replace(/\\\//g, '/'); + const inner = /^\^\(\?:(.*)\)\$$/s.exec(body)?.[1]; + return inner !== undefined && groupsBalance(inner) ? inner : body; +} + +/** Do the parentheses in `p` never close a group they did not open? */ +function groupsBalance(p: string): boolean { + let depth = 0; + for (let i = 0; i < p.length; i++) { + if (p[i] === '\\') i++; + else if (p[i] === '(') depth++; + else if (p[i] === ')' && --depth < 0) return false; + } + return depth === 0; +} diff --git a/src/generated/conventions.json b/src/generated/conventions.json index 0c426dc..d64ffd9 100644 --- a/src/generated/conventions.json +++ b/src/generated/conventions.json @@ -722,7 +722,8 @@ }, { "xlsform": "regex(., '...')", - "ls": "regexMatch(...)" + "ls": "regexMatch('/^(?:...)$/', self)", + "note": "LimeSurvey takes the pattern first and delimited (`/…/`, `/` escaped), and searches instead of matching the whole value as ODK does, so the pattern is wrapped in `^(?:…)$` (left alone when it is already anchored). lstsv2xlsform strips both and also reads the old field-first `regexMatch(x, 'pat')`." }, { "xlsform": "coalesce(x, y)", diff --git a/src/generated/conventions.ts b/src/generated/conventions.ts index 053c1af..e4a08b8 100644 --- a/src/generated/conventions.ts +++ b/src/generated/conventions.ts @@ -731,7 +731,8 @@ const conventions = { }, { "xlsform": "regex(., '...')", - "ls": "regexMatch(...)" + "ls": "regexMatch('/^(?:...)$/', self)", + "note": "LimeSurvey takes the pattern first and delimited (`/…/`, `/` escaped), and searches instead of matching the whole value as ODK does, so the pattern is wrapped in `^(?:…)$` (left alone when it is already anchored). lstsv2xlsform strips both and also reads the old field-first `regexMatch(x, 'pat')`." }, { "xlsform": "coalesce(x, y)", diff --git a/src/instrument/reverseExpressions.ts b/src/instrument/reverseExpressions.ts index 894f65f..bc5a08f 100644 --- a/src/instrument/reverseExpressions.ts +++ b/src/instrument/reverseExpressions.ts @@ -20,14 +20,9 @@ * counterpart to forward's own simplified (context-free) constraint * handling, not a separate loss on top of it. * - * NOT implemented: `convertConstraint`'s separate early-return path for a - * literal `regexMatch(...)` substring in the *original* XPath text (an - * escape-hatch letting authors write EM-flavored syntax directly in - * `constraint`) swaps `regexMatch(pattern, field)`'s argument order relative - * to the generic `transpile()` path's `regexMatch(field, pattern)`. Once - * transpiled, both look identical in the TSV, so which order applies can't be - * determined on the way back — `reverseConstraint` always assumes the - * generic (unswapped) order. No fixture uses either form. + * `regexMatch('/pat/', x)` reverses to `regex(x, 'pat')` (delimiters and the + * forward path's `^(?:…)$` anchoring removed); the pre-fix field-first + * `regexMatch(x, 'pat')` is still read (see `regexMatchToXPath`). * * `calculation` is out of scope entirely: the `calculate` XLSForm type isn't * registered, so the forward converter rejects it and never produces a @@ -36,6 +31,7 @@ import { parseEm, EmNode } from './emParser.js'; import { ConversionError } from '../diagnostics.js'; +import { lsPatternToOdk } from '../conventions/regexPattern.js'; import { COUNT_SELECTED } from '../conventions/selectionCount.js'; import { EMPTY_GUARD, @@ -112,9 +108,26 @@ function coalesceToXPath( return `coalesce(${x}, ${nodeToXPath(fallback, ctx)})`; } +/** + * `regexMatch('/pat/', x)` → `regex(x, 'pat')`, undoing the forward + * delimiters and anchoring. The pre-fix `regexMatch(x, 'pat')` (field first) + * is still read, as `regex(x, 'pat')`. `null` for any other call. + */ +function regexMatchToXPath( + node: EmNode & { t: 'call' }, + ctx: SelectContext, +): string | null { + if (node.name !== 'regexMatch' || node.args.length !== 2) return null; + const [first, second] = node.args; + if (first.t === 'str') { + const odk = lsPatternToOdk(first.v); + return `regex(${nodeToXPath(second, ctx)}, ${quote(odk ?? first.v)})`; + } + return `regex(${nodeToXPath(first, ctx)}, ${nodeToXPath(second, ctx)})`; +} + const FUNCTION_NAME_TO_XPATH: Record = { count: 'count', - regexMatch: 'regex', contains: 'contains', floor: 'floor', ceil: 'ceiling', @@ -232,6 +245,8 @@ function nodeToXPath(node: EmNode, ctx: SelectContext): string { if (counted) return counted; const coalesced = coalesceToXPath(node, ctx); if (coalesced) return coalesced; + const regex = regexMatchToXPath(node, ctx); + if (regex) return regex; const xfName = FUNCTION_NAME_TO_XPATH[node.name]; if (!xfName) { throw new ConversionError( diff --git a/src/pipelines/lstsv2xlsform/README.md b/src/pipelines/lstsv2xlsform/README.md index f895c8a..eda371d 100644 --- a/src/pipelines/lstsv2xlsform/README.md +++ b/src/pipelines/lstsv2xlsform/README.md @@ -143,14 +143,13 @@ listed here must match exactly. group, whether they come interleaved or in LimeSurvey's per-language blocks (`knownLosses.test.ts` covers a multi-group survey; `multilingual_survey` covers the fixture path). -7. **`regexMatch` argument order is assumed.** `convertConstraint()`'s - early-return path for a bare `regexMatch(...)` literal in the original XPath - (an escape hatch for EM-flavoured syntax) swaps the arguments — - `regexMatch(pattern, field)` — relative to the generic `transpile()` path's - `regexMatch(field, pattern)`. Once transpiled both look identical in the TSV, - so which one applied cannot be determined on the way back; - `reverseConstraint` always assumes the generic (unswapped) order. No fixture - uses either form. +7. **`regexMatch` reads back as a whole-string `regex()`.** ODK `regex()` + matches the whole value, LimeSurvey's `regexMatch('/pat/', x)` searches, so + the forward path writes `^(?:pat)$` and the reverse strips it again. A + LimeSurvey-native, unanchored pattern comes back unchanged and therefore + becomes whole-string in ODK; regex flags (`/pat/i`) have no ODK form and + are kept as text. The old field-first form `regexMatch(x, 'pat')` is still + read. 8. **`calculation` is not reversed — out of scope.** The `calculate` XLSForm type is not registered, so the forward converter rejects it and no TSV this tool produces contains a calculation question: there is nothing to reverse. diff --git a/src/pipelines/xlsform2lstsv/xpathTranspiler.ts b/src/pipelines/xlsform2lstsv/xpathTranspiler.ts index 5f57ce6..e246e9b 100644 --- a/src/pipelines/xlsform2lstsv/xpathTranspiler.ts +++ b/src/pipelines/xlsform2lstsv/xpathTranspiler.ts @@ -25,6 +25,10 @@ import { consoleWarning, warning } from '../../diagnostics.js'; import type { WarningHandler } from '../../diagnostics.js'; import { ConversionError } from '../../diagnostics.js'; import { COUNT_SELECTED } from '../../conventions/selectionCount.js'; +import { + delimitPattern, + odkPatternToLs, +} from '../../conventions/regexPattern.js'; /** * Callback to look up a sanitized answer code given a question name and original choice value. @@ -133,6 +137,20 @@ function arg(args: XPathNode[], i: number, fn: string): XPathNode { return node; } +/** + * `regex(x, 'pat')` → `regexMatch('/^(?:pat)$/', x)`: LimeSurvey takes the + * pattern first, delimited, and searches rather than matching the whole value. + */ +function transpileRegex(args: XPathNode[], ctx?: TranspilerContext): string { + const input = transpile(arg(args, 0, 'regex'), ctx); + const pattern = arg(args, 1, 'regex'); + const ls = + pattern.kind === 'str' + ? `${pattern.delim}${odkPatternToLs(pattern.value)}${pattern.delim}` + : `'/^(?:' + ${transpile(pattern, ctx)} + ')$/'`; + return `regexMatch(${ls}, ${input})`; +} + const FUNCTION_HANDLERS: Record< string, (args: XPathNode[], ctx?: TranspilerContext) => string @@ -158,7 +176,7 @@ const FUNCTION_HANDLERS: Record< // EM's `+` is also string concatenation, so any operator argument gets its // own parentheses: `a + b - c` would join strings, then subtract. concat: (args, ctx) => args.map((a) => operand(a, Infinity, ctx)).join(' + '), - regex: (args, ctx) => wrapArgs('regexMatch', args, ctx), + regex: (args, ctx) => transpileRegex(args, ctx), // 2-arg contains: (args, ctx) => `contains(${transpile(arg(args, 0, 'contains'), ctx)}, ${transpile(arg(args, 1, 'contains'), ctx)})`, @@ -409,9 +427,10 @@ function reconstructRegexMatch( if (!secondArgIsFieldRef(secondArg) || !firstArgLooksLikePattern(firstArg)) return null; const processedFieldArg = secondArg.replace(/\./g, 'self'); - const processedPatternArg = firstArg - .replace(/^"|"$/g, "'") - .replace(/\\'/g, "'"); + const unquoted = firstArg.replace(/^"|"$/g, "'").replace(/\\'/g, "'"); + const processedPatternArg = /^'.*'$/s.test(unquoted) + ? `'${delimitPattern(unquoted.slice(1, -1))}'` + : unquoted; return `regexMatch(${processedPatternArg}, ${processedFieldArg})`; } diff --git a/tests/fixtures/surveys/complex_survey/xlsform.json b/tests/fixtures/surveys/complex_survey/xlsform.json index f5867dd..a5d9ab8 100644 --- a/tests/fixtures/surveys/complex_survey/xlsform.json +++ b/tests/fixtures/surveys/complex_survey/xlsform.json @@ -133,4 +133,4 @@ "default_language": "en" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/surveys/complex_xpath_survey/xlsform.json b/tests/fixtures/surveys/complex_xpath_survey/xlsform.json index dab6fbb..a9a005c 100644 --- a/tests/fixtures/surveys/complex_xpath_survey/xlsform.json +++ b/tests/fixtures/surveys/complex_xpath_survey/xlsform.json @@ -63,4 +63,4 @@ "default_language": "en" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/surveys/multilingual_survey/xlsform.json b/tests/fixtures/surveys/multilingual_survey/xlsform.json index 4056b1b..5c5e166 100644 --- a/tests/fixtures/surveys/multilingual_survey/xlsform.json +++ b/tests/fixtures/surveys/multilingual_survey/xlsform.json @@ -119,4 +119,4 @@ "default_language": "en" } ] -} \ No newline at end of file +} diff --git a/tests/fixtures/surveys/multipage_survey/xlsform.json b/tests/fixtures/surveys/multipage_survey/xlsform.json index b14bcba..1b4754c 100644 --- a/tests/fixtures/surveys/multipage_survey/xlsform.json +++ b/tests/fixtures/surveys/multipage_survey/xlsform.json @@ -1,11 +1,25 @@ { "survey": [ - { "type": "begin_group", "name": "page1", "label": "Page One", "appearance": "field-list" }, + { + "type": "begin_group", + "name": "page1", + "label": "Page One", + "appearance": "field-list" + }, { "type": "text", "name": "name", "label": "Your name" }, { "type": "integer", "name": "age", "label": "Your age" }, { "type": "end_group" }, - { "type": "begin_group", "name": "page2", "label": "Page Two", "appearance": "field-list" }, - { "type": "select_one colors", "name": "favcolor", "label": "Favourite colour" }, + { + "type": "begin_group", + "name": "page2", + "label": "Page Two", + "appearance": "field-list" + }, + { + "type": "select_one colors", + "name": "favcolor", + "label": "Favourite colour" + }, { "type": "note", "name": "thankyou", "label": "Thank you!" }, { "type": "end_group" } ], @@ -15,6 +29,11 @@ { "list_name": "colors", "name": "green", "label": "Green" } ], "settings": [ - { "form_title": "Multi-Page Survey", "form_id": "multipage", "default_language": "en", "style": "pages" } + { + "form_title": "Multi-Page Survey", + "form_id": "multipage", + "default_language": "en", + "style": "pages" + } ] } diff --git a/tests/fixtures/surveys/testB/tsv.tsv b/tests/fixtures/surveys/testB/tsv.tsv index 500c7cd..ebd15d9 100644 --- a/tests/fixtures/surveys/testB/tsv.tsv +++ b/tests/fixtures/surveys/testB/tsv.tsv @@ -163,7 +163,7 @@ G 7 Über dich 1 de G 7 About you 1 en Q S firstname 1 Wie lautet dein Vorname? de Y 1 Q S lastname 1 Wie lautet dein Nachname? de Y 1 -Q S emailaddress 1 Unter welcher E-Mail Adresse können wir dich erreichen? de regexMatch(self, '^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$') Y 1 Please specify a valid email address. +Q S emailaddress 1 Unter welcher E-Mail Adresse können wir dich erreichen? de regexMatch('/^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$/', self) Y 1 Please specify a valid email address. Q L gender 1 Was ist dein Geschlecht? Hinweis: Da wir bei CorrelAid nach dem Grundprinzip von Geschlechtergleichberechtigung arbeiten, ist diese Frage für uns besonders wichtig. de Y 1 A femal Weiblich de A male Männlich de @@ -173,7 +173,7 @@ A selfi Mein Geschlecht ist (eigene Angabe): de Q S genderselfidentifica gender == 'selfi' Mein Geschlecht ist: Bitte gebe hier dein Geschlecht an. de Y 1 Q S firstname 1 What is your first name? en Y 1 Q S lastname 1 What is your last name? en Y 1 -Q S emailaddress 1 Which email address can we use to contact you? en regexMatch(self, '^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$') Y 1 Please specify a valid email address. +Q S emailaddress 1 Which email address can we use to contact you? en regexMatch('/^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$/', self) Y 1 Please specify a valid email address. Q L gender 1 What is your gender? Note: We ask this question because we want to foster gender diversity within CorrelAid. en Y 1 A femal Female en A male Male en diff --git a/tests/live/limesurvey/answers/select_multiple.json b/tests/live/limesurvey/answers/select_multiple.json index c5d79a2..21aaa7b 100644 --- a/tests/live/limesurvey/answers/select_multiple.json +++ b/tests/live/limesurvey/answers/select_multiple.json @@ -1,6 +1,3 @@ { - "wochenende": [ - "sa", - "so" - ] + "wochenende": ["sa", "so"] } diff --git a/tests/live/limesurvey/answers/select_multiple_long_list.json b/tests/live/limesurvey/answers/select_multiple_long_list.json index 570a533..48d6e26 100644 --- a/tests/live/limesurvey/answers/select_multiple_long_list.json +++ b/tests/live/limesurvey/answers/select_multiple_long_list.json @@ -1,7 +1,3 @@ { - "besuchtelaender": [ - "DE", - "FR", - "JP" - ] + "besuchtelaender": ["DE", "FR", "JP"] } diff --git a/tests/live/limesurvey/docker-compose.yml b/tests/live/limesurvey/docker-compose.yml index bbfe599..1a7ee02 100644 --- a/tests/live/limesurvey/docker-compose.yml +++ b/tests/live/limesurvey/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - mysql_data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost'] timeout: 5s retries: 10 @@ -18,7 +18,7 @@ services: # LIMESURVEY_PORT moves it off 8080 when that port is taken; set the same # variable for codegen --screenshots (codegen/limesurvey_stack.py). ports: - - "${LIMESURVEY_PORT:-8080}:80" + - '${LIMESURVEY_PORT:-8080}:80' environment: LIMESURVEY_DB_TYPE: mysql LIMESURVEY_DB_HOST: mysql @@ -28,7 +28,7 @@ services: LIMESURVEY_DB_PASSWORD: limesurvey LIMESURVEY_ADMIN_USER: admin LIMESURVEY_ADMIN_PASSWORD: admin - LIMESURVEY_ADMIN_NAME: "Administrator" + LIMESURVEY_ADMIN_NAME: 'Administrator' LIMESURVEY_ADMIN_EMAIL: admin@example.com LIMESURVEY_BASE_URL: http://localhost:${LIMESURVEY_PORT:-8080} LIMESURVEY_API_MODE: json @@ -36,7 +36,7 @@ services: mysql: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:80/index.php/admin"] + test: ['CMD', 'curl', '-f', 'http://localhost:80/index.php/admin'] interval: 10s timeout: 5s retries: 30 diff --git a/tests/ts/unit/expressions/ast.test.ts b/tests/ts/unit/expressions/ast.test.ts index 7326edb..eab9ae1 100644 --- a/tests/ts/unit/expressions/ast.test.ts +++ b/tests/ts/unit/expressions/ast.test.ts @@ -55,7 +55,7 @@ describe('AST parsing', () => { const limeSurveyExpr = await xpathToLimeSurvey(xpathExpr); console.log(limeSurveyExpr); - expect(limeSurveyExpr).toBe('regexMatch(self, "\\p{L}+")'); + expect(limeSurveyExpr).toBe('regexMatch("/^(?:\\p{L}+)$/", self)'); }); test('should parse contains function', async () => { diff --git a/tests/ts/unit/expressions/xpathConstraintConverter.test.ts b/tests/ts/unit/expressions/xpathConstraintConverter.test.ts index 35a674f..5e80b04 100644 --- a/tests/ts/unit/expressions/xpathConstraintConverter.test.ts +++ b/tests/ts/unit/expressions/xpathConstraintConverter.test.ts @@ -41,7 +41,7 @@ describe('XPath Constraint Conversion', () => { 'regexMatch("^[A-Z][a-z]+$", name)', ); // regex() is supported and mapped to regexMatch() - expect(result).toBe(`regexMatch('^[A-Z][a-z]+$', name)`); + expect(result).toBe(`regexMatch('/^[A-Z][a-z]+$/', name)`); }); }); @@ -152,12 +152,22 @@ describe('XPath Constraint Conversion', () => { describe('regexMatch function extraction', () => { test('returns regexMatch for supported regex patterns', async () => { const result = await convertConstraint('regexMatch("^[A-Z][a-z]+$", .)'); - expect(result).toBe(`regexMatch('^[A-Z][a-z]+$', self)`); + expect(result).toBe(`regexMatch('/^[A-Z][a-z]+$/', self)`); }); test('returns regexMatch for complex regex patterns', async () => { const result = await convertConstraint('regexMatch("^[0-9]{5}$", .)'); - expect(result).toBe(`regexMatch('^[0-9]{5}$', self)`); + expect(result).toBe(`regexMatch('/^[0-9]{5}$/', self)`); + }); + + test('keeps an already delimited pattern as written', async () => { + const result = await convertConstraint(`regexMatch("/^[0-9]{5}$/", .)`); + expect(result).toBe(`regexMatch('/^[0-9]{5}$/', self)`); + }); + + test('escapes a slash when delimiting a bare pattern', async () => { + const result = await convertConstraint(`regexMatch("^a/b$", .)`); + expect(result).toBe(`regexMatch('/^a\\/b$/', self)`); }); test('returns empty for regexMatch with different argument order', async () => { diff --git a/tests/ts/unit/expressions/xpathFunctions.test.ts b/tests/ts/unit/expressions/xpathFunctions.test.ts index 61d4817..86c9286 100644 --- a/tests/ts/unit/expressions/xpathFunctions.test.ts +++ b/tests/ts/unit/expressions/xpathFunctions.test.ts @@ -32,12 +32,49 @@ describe('XPath Function Conversion', () => { describe('regex() function', () => { test('converts regex() with field reference', async () => { const result = await xpathToLimeSurvey('regex(${name}, "^[A-Z][a-z]+$")'); - expect(result).toBe('regexMatch(name, "^[A-Z][a-z]+$")'); + expect(result).toBe('regexMatch("/^[A-Z][a-z]+$/", name)'); }); test('converts regex() with current field', async () => { const result = await xpathToLimeSurvey('regex(., "^[A-Z][a-z]+$")'); - expect(result).toBe('regexMatch(self, "^[A-Z][a-z]+$")'); + expect(result).toBe('regexMatch("/^[A-Z][a-z]+$/", self)'); + }); + + test('anchors an unanchored pattern, since ODK matches the whole value', async () => { + const result = await xpathToLimeSurvey("regex(., '[0-9]{5}')"); + expect(result).toBe("regexMatch('/^(?:[0-9]{5})$/', self)"); + }); + + test('groups alternation so the anchors cover every branch', async () => { + const result = await xpathToLimeSurvey("regex(., 'a|b')"); + expect(result).toBe("regexMatch('/^(?:a|b)$/', self)"); + }); + + test('escapes / in the pattern, once', async () => { + expect(await xpathToLimeSurvey("regex(., '^a/b\\/c$')")).toBe( + "regexMatch('/^a\\/b\\/c$/', self)", + ); + }); + + test('keeps the quote style and the other quote char in the pattern', async () => { + expect(await xpathToLimeSurvey(`regex(., "^[a-z\']+$")`)).toBe( + `regexMatch("/^[a-z']+$/", self)`, + ); + expect(await xpathToLimeSurvey(`regex(., '^"x"$')`)).toBe( + `regexMatch('/^"x"$/', self)`, + ); + }); + + test('a pattern ending in an escaped dollar is not anchored', async () => { + expect(await xpathToLimeSurvey("regex(., '^a\\$')")).toBe( + "regexMatch('/^(?:^a\\$)$/', self)", + ); + }); + + test('a computed pattern is delimited and anchored at run time', async () => { + expect(await xpathToLimeSurvey("regex(., concat('a', ${b}))")).toBe( + "regexMatch('/^(?:' + 'a' + b + ')$/', self)", + ); }); }); diff --git a/tests/ts/unit/pipelines/lstsv2xlsform/knownLosses.test.ts b/tests/ts/unit/pipelines/lstsv2xlsform/knownLosses.test.ts index a5663b2..ab11cd4 100644 --- a/tests/ts/unit/pipelines/lstsv2xlsform/knownLosses.test.ts +++ b/tests/ts/unit/pipelines/lstsv2xlsform/knownLosses.test.ts @@ -67,28 +67,22 @@ describe('README known losses', () => { }); }); - test('regexMatch: the reverse assumes the generic (field, pattern) order', async () => { + test('regexMatch: a LimeSurvey-native unanchored pattern becomes ODK whole-string', async () => { const { tsv, back } = await roundTrip([ - // XPath regex(): the generic path, which round-trips. - { - type: 'text', - name: 'a', - label: 'A?', - constraint: "regex(., '^[0-9]{5}$')", - }, - // An EM-style regexMatch(pattern, field) takes the escape hatch, which - // swaps the arguments; the TSV can't show which path applied. + { type: 'text', name: 'a', label: 'A?', constraint: "regex(., '[0-9]')" }, + // An author-written EM regexMatch is searched, not anchored: ODK will + // match the whole value instead. { type: 'text', name: 'b', label: 'B?', - constraint: "regexMatch('^[0-9]{5}$', .)", + constraint: "regexMatch('/[0-9]/', .)", }, ]); - expect(tsv).toContain("regexMatch(self, '^[0-9]{5}$')"); - expect(tsv).toContain("regexMatch('^[0-9]{5}$', self)"); - expect(question(back, 'a')?.constraint).toBe("regex(., '^[0-9]{5}$')"); - expect(question(back, 'b')?.constraint).toBe("regex('^[0-9]{5}$', .)"); + expect(tsv).toContain("regexMatch('/^(?:[0-9])$/', self)"); + expect(tsv).toContain("regexMatch('/[0-9]/', self)"); + expect(question(back, 'a')?.constraint).toBe("regex(., '[0-9]')"); + expect(question(back, 'b')?.constraint).toBe("regex(., '[0-9]')"); }); test('a multilingual survey with several groups keeps every translation', async () => { diff --git a/tests/ts/unit/pipelines/lstsv2xlsform/reverseExpressions.test.ts b/tests/ts/unit/pipelines/lstsv2xlsform/reverseExpressions.test.ts index 7d9bb9e..c42e6c4 100644 --- a/tests/ts/unit/pipelines/lstsv2xlsform/reverseExpressions.test.ts +++ b/tests/ts/unit/pipelines/lstsv2xlsform/reverseExpressions.test.ts @@ -253,3 +253,31 @@ describe('reverseRelevance — exact output for #98', () => { expect(reverseRelevance(`a == "it's"`, selectCtx)).toBe(`\${a} = "it's"`); }); }); + +describe('reverseConstraint — regexMatch', () => { + test.each([ + ["regexMatch('/^[0-9]{5}$/', self)", "regex(., '^[0-9]{5}$')"], + ["regexMatch('/^(?:[0-9]{5})$/', self)", "regex(., '[0-9]{5}')"], + ["regexMatch('/^(?:a|b)$/', zip)", "regex(${zip}, 'a|b')"], + ["regexMatch('/^a\\/b$/', self)", "regex(., '^a/b$')"], + // LimeSurvey-native, unanchored: kept as written. + ["regexMatch('/abc/', self)", "regex(., 'abc')"], + // Flags have no ODK form: the delimited text is kept. + ["regexMatch('/abc/i', self)", "regex(., '/abc/i')"], + // The pre-fix output, field first and undelimited, is still read. + ["regexMatch(self, '^[0-9]{5}$')", "regex(., '^[0-9]{5}$')"], + ["regexMatch(zip, '^[0-9]{5}$')", "regex(${zip}, '^[0-9]{5}$')"], + ])('%s', (em, xpath) => { + expect(reverseConstraint(em)).toBe(xpath); + }); + + test.each([ + "regex(., '^[0-9]{5}$')", + "regex(., '[0-9]{5}')", + "regex(., 'a|b')", + "regex(., '^a/b$')", + "regex(., '^a\\$')", + ])('forward then reverse restores %s', async (xpath) => { + expect(reverseConstraint(await convertConstraint(xpath))).toBe(xpath); + }); +});