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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,30 @@ codebook describes a *dataset*, not an *instrument* — it carries no relevance,
constraint, required, default or appearance, so reversing it would emit a survey
that looks right and behaves wrongly.

## Errors and warnings

Every error the library throws is a `ConversionError` with a stable `code`
(`type-unregistered`, `choice-list-empty`, `xpath-syntax`, …; see
`DiagnosticCode`), a human `message`, and `subject`, the question it concerns.
Branch on `code`, not on the message text.

Warnings (a truncated name, an ignored appearance, a constraint that can't be
converted) go to an `onWarning` callback. The default prints them to the
console:

```ts
const warnings: Diagnostic[] = [];
await new XLSFormToTSVConverter({ onWarning: (w) => warnings.push(w) }).convert(
survey,
choices,
settings,
);
XLSLoader.parseXLSData(buffer, { onWarning: (w) => warnings.push(w) });
```

`validateSubset` returns the same `Diagnostic` objects for every finding at
once, instead of throwing on the first.

## Supported XLSForm Subset

Not everything XLSForm allows is registered (supported). The library strictly
Expand Down
11 changes: 9 additions & 2 deletions src/config/resolveConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { deepMerge } from '../utils/helpers.js';

import { defaultConfig, LstsvConfig } from './types.js';
import { ConversionError } from '../diagnostics.js';

const REPEAT_MODES = ['warn', 'error', 'ignore'];

Expand All @@ -14,13 +15,19 @@ export function resolveConfig(
const config = deepMerge(structuredClone(defaultConfig), partial);

if (config.handleRepeats && !REPEAT_MODES.includes(config.handleRepeats)) {
throw new Error(`Invalid handleRepeats option: ${config.handleRepeats}`);
throw new ConversionError(
'config-invalid',
`Invalid handleRepeats option: ${config.handleRepeats}`,
);
}
// The whole xlsform2lstsv pipeline handles 2-letter codes only
// (isValidLanguageCode), although convention:languageTagging allows BCP 47
// tags such as fr-BE; tracked separately.
if (!config.defaults.language || config.defaults.language.length !== 2) {
throw new Error('defaults.language must be a 2-character language code');
throw new ConversionError(
'config-invalid',
'defaults.language must be a 2-character language code',
);
}

return Object.freeze(config);
Expand Down
9 changes: 9 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* The XLSForm row types used to live here; they are in `src/xlsform/types.ts`
* now and re-exported below for compatibility.
*/
import type { WarningHandler } from '../diagnostics.js';

export type {
SurveyRow,
ChoiceRow,
Expand Down Expand Up @@ -56,6 +58,13 @@ export interface LstsvConfig {
*/
hideQuestionTips?: boolean;

/**
* Receives the conversion's non-fatal findings (truncated names, ignored
* appearances, a dropped constraint, …) with a stable `code`. Defaults to
* printing them with `console.warn`.
*/
onWarning?: WarningHandler;

/**
* Default values for survey elements
*/
Expand Down
7 changes: 6 additions & 1 deletion src/conventions/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
* DDI: `<varGrp type="grid">`.
*/
import conventions from '../generated/conventions.js';
import { ConversionError } from '../diagnostics.js';

const GRID = conventions.composites.find((c) => c.id === 'grid');
if (!GRID) throw new Error('registry has no grid composite');
if (!GRID)
throw new ConversionError(
'registry-invalid',
'registry has no grid composite',
);

/** The `begin_group` appearance that makes a group a grid. */
export const GRID_APPEARANCE: string = GRID.trigger.appearance;
Expand Down
130 changes: 125 additions & 5 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,130 @@
/**
* Findings the validators report. Shared by every format module, so it lives
* outside all of them.
* Structured findings: what the validators report and the converters throw or
* warn with. Every finding carries a stable `code`, so a consumer can translate
* it or point the user at the offending question instead of parsing messages.
* Shared by every format module and pipeline, so it lives outside all of them.
*/

/** One finding: an `error` blocks a lossless conversion, a `warning` doesn't. */
export interface SubsetViolation {
severity: 'error' | 'warning';
/** Stable identifiers for every finding the library produces. */
export type DiagnosticCode =
// XLSForm structure and subset
| 'sheet-missing'
| 'sheet-empty'
| 'column-missing'
| 'column-unexpected'
| 'language-invalid'
| 'name-invalid'
| 'name-too-long'
| 'name-duplicate'
| 'name-empty-after-sanitize'
| 'name-truncated'
| 'name-collision'
| 'code-invalid'
| 'code-too-long'
| 'code-duplicate'
| 'code-missing'
| 'code-empty-after-sanitize'
| 'code-truncated'
| 'label-missing'
| 'type-unregistered'
| 'type-unsupported'
| 'choice-list-missing'
| 'choice-list-empty'
| 'vocab-file-missing'
| 'vocab-unregistered'
| 'vocab-csv-invalid'
| 'appearance-unregistered'
| 'appearance-invalid-for-type'
| 'exclusive-invalid'
| 'exclusive-no-effect'
| 'other-label-noncanonical'
| 'parameter-invalid'
// expressions
| 'xpath-syntax'
| 'xpath-unsupported'
| 'constraint-dropped'
// LimeSurvey TSV (reverse)
| 'lstsv-invalid'
| 'lstsv-outside-subset'
| 'em-unsupported'
// responses
| 'responses-invalid'
| 'response-ambiguous'
// configuration and registry
| 'config-invalid'
| 'registry-invalid';

export type Severity = 'error' | 'warning';

/** One finding. `name` is the question (or choice list) it concerns, if any. */
export interface Diagnostic {
code: DiagnosticCode;
severity: Severity;
message: string;
name?: string;
}

/**
* What `validateSubset` and `validateLstsvSubset` return: an `error` blocks a
* lossless conversion, a `warning` doesn't.
*/
export type SubsetViolation = Diagnostic;

/** Receives the warnings a conversion produces. */
export type WarningHandler = (warning: Diagnostic) => void;

/**
* The default handler: prints to the console, as the library always did. The
* only place the library writes to the console; pass your own handler (e.g. to
* collect warnings in a UI) via the `onWarning` options.
*/
export const consoleWarning: WarningHandler = (w) => {
console.warn(w.message);
};

/** Build a warning. */
export function warning(
code: DiagnosticCode,
message: string,
name?: string,
): Diagnostic {
return { code, severity: 'warning', message, ...(name ? { name } : {}) };
}

/**
* The error every library function throws. `code` is stable; `message` is for
* humans and may change.
*/
export class ConversionError extends Error implements Diagnostic {
readonly severity = 'error' as const;
readonly code: DiagnosticCode;
readonly name: string;
/** The question (or choice list) concerned, if any. */
readonly subject?: string;
/** Every finding, when the error summarises several. */
readonly details: readonly Diagnostic[];

constructor(
code: DiagnosticCode,
message: string,
options: {
subject?: string;
cause?: unknown;
details?: readonly Diagnostic[];
} = {},
) {
super(message);
this.name = 'ConversionError';
this.code = code;
this.details = options.details ?? [];
if (options.subject) this.subject = options.subject;
if (options.cause !== undefined) {
(this as { cause?: unknown }).cause = options.cause;
}
}

/** Rethrow a finding as an error. */
static from(d: Diagnostic): ConversionError {
return new ConversionError(d.code, d.message, { subject: d.name });
}
}
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
// ── Diagnostics ────────────────────────────────────────────────────────
export { ConversionError, consoleWarning } from './diagnostics.js';
export type {
Diagnostic,
DiagnosticCode,
Severity,
WarningHandler,
} from './diagnostics.js';

// ── Format modules ─────────────────────────────────────────────────────
export { XLSLoader } from './xlsform/loader.js';
export type { LoadOptions } from './xlsform/loader.js';
export { XLSFormParser } from './pipelines/xlsform2lstsv/xlsformParser.js';
export { XLSValidator } from './xlsform/validate.js';
export type {
Expand Down
2 changes: 2 additions & 0 deletions src/lstsv/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ export function validateLstsvSubset(rows: Row[]): SubsetViolation[] {
if (!code || SUPPORTED_LS_CODES.has(code)) continue;
const where = row.name?.trim() ? ` (question "${row.name.trim()}")` : '';
violations.push({
code: 'lstsv-outside-subset',
severity: 'error',
message: `unsupported LimeSurvey question type "${code}"${where} — not in the transformable subset (${[...SUPPORTED_LS_CODES].sort().join(', ')})`,
...(row.name?.trim() ? { name: row.name.trim() } : {}),
});
}
return violations;
Expand Down
4 changes: 3 additions & 1 deletion src/pipelines/lstsv2ddi/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { Variable } from '../../ddi/types.js';
import type { Submission } from '../../ddi/data.js';

import { OTHER_CODE, OTHER_SUFFIX } from '../../conventions/other.js';
import { ConversionError } from '../../diagnostics.js';

/** LimeSurvey's stored value for the "other" option of a list question. */
const LS_OTHER_VALUE = '-oth-';
Expand Down Expand Up @@ -93,7 +94,8 @@ function matchChoice(subkey: string, variable: Variable): string | null {
);
if (prefixed.length === 1) return prefixed[0];
if (prefixed.length > 1) {
throw new Error(
throw new ConversionError(
'response-ambiguous',
`ambiguous LimeSurvey option column ${variable.name}[${subkey}]: matches ` +
`choice codes ${prefixed.join(', ')}. Codes must be unique in their ` +
'first 5 characters for a LimeSurvey export to be unambiguous.',
Expand Down
4 changes: 3 additions & 1 deletion src/pipelines/lstsv2ddi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Submission } from '../../ddi/data.js';
import { normalizeLimeSurveyResponses } from './data.js';
import type { NormalizeResponsesOptions } from './data.js';
import { lstsvToVariables } from './toVariables.js';
import { ConversionError } from '../../diagnostics.js';

export { parseLstsv } from '../../lstsv/parser.js';
export { lstsvToVariables } from './toVariables.js';
Expand All @@ -43,7 +44,8 @@ function parseChecked(
const violations = validateLstsvSubset(rows);
const errors = violations.filter((v) => v.severity === 'error');
if (errors.length > 0) {
throw new Error(
throw new ConversionError(
'lstsv-outside-subset',
`LimeSurvey TSV uses ${errors.length} feature(s) outside the transformable subset:\n - ` +
errors.map((e) => e.message).join('\n - '),
);
Expand Down
32 changes: 26 additions & 6 deletions src/pipelines/lstsv2xlsform/emParser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConversionError } from '../../diagnostics.js';
/**
* Parse the (bounded) LimeSurvey Expression Manager dialect the forward
* transpiler emits (`src/converters/xpathTranspiler.ts`) and serialize it back
Expand Down Expand Up @@ -68,7 +69,10 @@ function scanString(
let j = i + 1;
while (j < src.length && src[j] !== quote) j++;
if (j >= src.length) {
throw new Error(`unterminated string literal in: ${src}`);
throw new ConversionError(
'em-unsupported',
`unterminated string literal in: ${src}`,
);
}
return { token: { type: 'str', value: src.slice(i + 1, j) }, next: j + 1 };
}
Expand Down Expand Up @@ -96,7 +100,10 @@ function scanOperator(src: string, i: number): { token: Token; next: number } {
if (SINGLE_CHAR_OPS.includes(src[i])) {
return { token: { type: 'op', value: src[i] }, next: i + 1 };
}
throw new Error(`unsupported character "${src[i]}" in expression: ${src}`);
throw new ConversionError(
'em-unsupported',
`unsupported character "${src[i]}" in expression: ${src}`,
);
}

// ── AST ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -136,23 +143,33 @@ class Parser {

private next(): Token {
const tok = this.tokens[this.pos];
if (!tok) throw new Error('unexpected end of expression');
if (!tok)
throw new ConversionError(
'em-unsupported',
'unexpected end of expression',
);
this.pos++;
return tok;
}

private expect(type: TokenType): Token {
const tok = this.next();
if (tok.type !== type) {
throw new Error(`expected ${type}, got "${tok.value}"`);
throw new ConversionError(
'em-unsupported',
`expected ${type}, got "${tok.value}"`,
);
}
return tok;
}

parse(): EmNode {
const node = this.parseBinary(1);
if (this.pos < this.tokens.length) {
throw new Error(`unexpected trailing token "${this.peek()!.value}"`);
throw new ConversionError(
'em-unsupported',
`unexpected trailing token "${this.peek()!.value}"`,
);
}
return node;
}
Expand Down Expand Up @@ -213,7 +230,10 @@ class Parser {
const name = naok ? tok.value.slice(0, -'.NAOK'.length) : tok.value;
return { t: 'ident', name, naok };
}
throw new Error(`unexpected token "${tok.value}"`);
throw new ConversionError(
'em-unsupported',
`unexpected token "${tok.value}"`,
);
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/pipelines/lstsv2xlsform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { SubsetViolation } from '../../diagnostics.js';

import { lstsvRowsToXlsform } from './toXlsform.js';
import type { XlsformOutput } from './toXlsform.js';
import { ConversionError } from '../../diagnostics.js';

export { lstsvRowsToXlsform } from './toXlsform.js';
export type { XlsformOutput } from './toXlsform.js';
Expand All @@ -38,7 +39,8 @@ export function lstsvToXlsform(
(v) => v.severity === 'error',
);
if (errors.length > 0) {
throw new Error(
throw new ConversionError(
'lstsv-outside-subset',
`LimeSurvey TSV uses ${errors.length} feature(s) outside the transformable subset:\n - ` +
errors.map((e) => e.message).join('\n - '),
);
Expand Down
Loading
Loading