Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion registry/conventions/logicMapping.jsonld
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
65 changes: 65 additions & 0 deletions src/conventions/regexPattern.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion src/generated/conventions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 2 additions & 1 deletion src/generated/conventions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
33 changes: 24 additions & 9 deletions src/instrument/reverseExpressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, string> = {
count: 'count',
regexMatch: 'regex',
contains: 'contains',
floor: 'floor',
ceil: 'ceiling',
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 7 additions & 8 deletions src/pipelines/lstsv2xlsform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 23 additions & 4 deletions src/pipelines/xlsform2lstsv/xpathTranspiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)})`,
Expand Down Expand Up @@ -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})`;
}

Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/surveys/complex_survey/xlsform.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,4 @@
"default_language": "en"
}
]
}
}
2 changes: 1 addition & 1 deletion tests/fixtures/surveys/complex_xpath_survey/xlsform.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@
"default_language": "en"
}
]
}
}
2 changes: 1 addition & 1 deletion tests/fixtures/surveys/multilingual_survey/xlsform.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,4 @@
"default_language": "en"
}
]
}
}
27 changes: 23 additions & 4 deletions tests/fixtures/surveys/multipage_survey/xlsform.json
Original file line number Diff line number Diff line change
@@ -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" }
],
Expand All @@ -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"
}
]
}
4 changes: 2 additions & 2 deletions tests/fixtures/surveys/testB/tsv.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 1 addition & 4 deletions tests/live/limesurvey/answers/select_multiple.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
{
"wochenende": [
"sa",
"so"
]
"wochenende": ["sa", "so"]
}
6 changes: 1 addition & 5 deletions tests/live/limesurvey/answers/select_multiple_long_list.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
{
"besuchtelaender": [
"DE",
"FR",
"JP"
]
"besuchtelaender": ["DE", "FR", "JP"]
}
8 changes: 4 additions & 4 deletions tests/live/limesurvey/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -28,15 +28,15 @@ 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
depends_on:
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
Expand Down
2 changes: 1 addition & 1 deletion tests/ts/unit/expressions/ast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading