Compare commits

...
19 Commits
Author SHA1 Message Date
hypercross 7802af577c chore: bump version to 2.0.0 2026-08-06 11:08:30 +08:00
hypercross 1a741b09a8 fix(esbuild): correct exclude pattern logic in csvLoader 2026-08-06 10:52:01 +08:00
hypercross 6da2815301 refactor: decouple table loading from lookup generation
Extract `loadReferenceTableData` to separate the raw file loading
logic from the primary key lookup construction. This prevents
unnecessary Map creation during reverse reference resolution.
2026-08-06 10:42:10 +08:00
hypercross 641af7341a perf(csv-loader): optimize reverse reference resolution
Implement a reverse lookup cache to avoid re-filtering the entire
referenced table for every row during reverse reference resolution.
This improves performance from O(N*M) to O(N+M) where N is the number
of rows in the current table and M is the number of rows in the
referenced table.

Also update documentation to reflect the new union resolution
behavior and migration notes for version 2.0.0.
2026-08-06 10:27:18 +08:00
hypercross 37e3514c0c fix(csv-loader): escape quotes in schema rows
Implement loader-side escaping for the schema row to prevent
`csv-parse` from misinterpreting double-quoted string literals (e.g.,
`"active" | "inactive"`) as field delimiters.
2026-08-06 10:25:24 +08:00
hypercross cdff31c126 docs: update union member resolution documentation
Refactor union resolution to explicitly try reference members before
non-reference members. This replaces the previous error-message-based
fallback with a deterministic, structural approach.

- Update `parseValueWithReferences` and `resolveNestedReferences` to
  partition members by reference presence.
- Update documentation in `README.md`, `AGENTS.md`, and
  `syntax-rework-plan.md` to reflect this behavior.
2026-08-06 09:32:10 +08:00
hypercross c969c7f6fc refactor: enforce mandatory brackets for composite values
Implements Phase 1 and 2 of the syntax rework plan:
- Brackets `[]` are now mandatory for all tuple and array values.
- Removed the `[Type][]` array syntax; arrays now only use `Type[]`.
- `[single]` is now strictly a 1-tuple rather than an array.
- Updated documentation and test fixtures to reflect these breaking
  changes.
2026-08-06 09:19:39 +08:00
hypercross 7db0742f50 docs: add syntax rework plan 2026-08-06 09:05:09 +08:00
hypercross a4e17a3c0b refactor: use browser-compatible csv-parse entry point
Update the `csv-parse` import to use the browser ESM sync version
and remove unused `fs` and `path` imports in `loader.ts`.
2026-08-06 08:40:37 +08:00
hypercross a3e0e953ff docs: update README and loader documentation 2026-06-13 10:24:42 +08:00
hypercross ea98399c24 refactor: rename package from inline-schema to typed-csv 2026-06-13 10:22:18 +08:00
hypercross 89be2783d1 fix(csv-loader): use commas in generated type declarations
Update `type-gen.ts` to replace semicolons with commas in the
generated TypeScript type definitions to ensure valid syntax.
2026-04-22 19:23:49 +08:00
hypercross eef3dbfac8 feat(csv-loader): resolve @table references in type declarations
Update type generation to transform @table references into capitalized
type names when using custom schema strings. This ensures that
generated TypeScript types correctly reference other CSV modules.
2026-04-22 18:15:55 +08:00
hypercross e0317946d5 feat(csv-loader): expand custom type references in schemas
Allow custom type names to be expanded before parsing the schema
string. This enables using declared types within complex structures
like tuples or arrays. The original schema string is preserved for
type generation to ensure the output uses the named type rather than
the expanded inline definition.
2026-04-22 17:58:36 +08:00
hypercross 46504a53dd feat(csv-loader): preserve original schema strings in type generation
Store the original schema string during CSV parsing to prevent
unnecessary expansion of type name references in the generated
TypeScript definitions. This ensures that declared types reference
each other by name rather than inlining their full definitions.
2026-04-22 17:08:06 +08:00
hypercross ea362d4229 test(csv-loader): add tests for custom type expansion in tuples and
arrays
2026-04-22 16:43:42 +08:00
hypercross f101c12097 feat(csv-loader): export generated type definitions 2026-04-22 16:39:41 +08:00
hypercross f03226221d test: add support for array suffixes in type expansion
Update `expandSchemaInString` to handle the `[]` suffix, allowing
type aliases to represent arrays (e.g., `IntentEffects[]`). Added a
fixture and test case to verify parsing of tuple arrays and type
aliases.
2026-04-22 16:37:54 +08:00
hypercross 1061e12c81 feat: enrich ParseError with schema and value context
Include the schema string and the input value in `ParseError` to
provide more helpful debugging information when parsing fails.
2026-04-22 16:14:23 +08:00
22 changed files with 758 additions and 242 deletions
+4 -3
View File
@@ -13,12 +13,12 @@ No linter or formatter is configured. No CI pipeline exists.
Single-package TypeScript library with two runtime entry points:
- **`inline-schema`** (`src/index.ts`) — core schema parser, value parser, and validator
- **`typed-csv`** (`src/index.ts`) — core schema parser, value parser, and validator
- `src/parser.ts` — `parseSchema()` turns schema strings into AST (`Schema` type)
- `src/validator.ts` — `parseValue()` and `createValidator()` operate on the AST
- `src/types.ts` — union type `Schema = Primitive | Tuple | Array | Reference | StringLiteral | Union`
- **`inline-schema/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution
- **`typed-csv/csv-loader`** (`src/csv-loader/loader.ts`) — CSV loader with `@table` reference resolution
- `loader.ts` — `parseCsv()` (eager resolution) and `csvToModule()` (accessor-based output with lazy resolution); `resolveReferences: false` mode stores IDs instead of resolved objects
- `webpack.ts`, `rollup.ts`, `esbuild.ts` — bundler plugin wrappers around `csvToModule`
@@ -28,6 +28,7 @@ Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-
- Schema syntax uses **semicolons** (`;`) as separators, not commas
- Unknown identifiers throw a `ParseError` — only recognized keywords (`string`, `number`, `int`, `float`, `boolean`) and string literals (`"on"`, `'off'`) are valid types
- Tuple and array **values** must be wrapped in brackets `[]` (e.g. `[a; b]`); `[single]` is a 1-tuple, not an array — use `Type[]` for arrays
- `@tablename` / `@tablename[]` are reference schemas resolved at CSV load time
- `csvToModule()` emits accessor functions (`getData()`) for tables with references, and static JSON for tables without; bundler loaders all use `csvToModule`
- `parseCsv({ resolveReferences: false })` stores reference IDs instead of resolved objects — used by `csvToModule` to emit import-based lazy resolution
@@ -37,6 +38,6 @@ Build produces separate bundles per entry point (see `tsup.config.ts`). The csv-
- **Circular references** between CSV tables are supported in `csvToModule` output via accessor-based lazy resolution. `parseCsv()` with `resolveReferences: true` (default) still detects and throws on circular references via an in-progress loading set.
- **Run `npm run typecheck` before committing** to catch type errors.
- **Union member ordering matters** — `parseValue` tries union members in order; the first one that parses wins. This affects references in unions (e.g., `@users[] | string` will try `@users[]` first).
- **Union member ordering** — In unions containing references, **reference members are tried before non-reference members** (regardless of author order), so `@users | string` resolves `1` to the user object and falls back to a plain string only when the reference doesn't match. `parseValue` (no references) still tries members in author order; the first that parses wins.
- **csv-parse quote handling** — Double-quoted schema values like `"active" | "inactive"` in CSV rows confuse the csv-parse library. Use single-quoted string literals (`'on' | 'off'`) or unquoted identifiers in the schema row of CSV data when possible.
- **Module imports use `.js` extension** — source files import from `../index.js` etc. (ESM convention), not `../index.ts`.
+52 -31
View File
@@ -1,11 +1,11 @@
# inline-schema
# typed-csv
A TypeScript library for parsing and validating inline schemas with a TypeScript-like syntax using `;` instead of `,`.
A TypeScript library for typed CSV data with inline schema validation using a TypeScript-like syntax with `;` instead of `,`.
## Installation
```bash
npm install inline-schema
npm install typed-csv
```
## Usage
@@ -13,7 +13,7 @@ npm install inline-schema
### Basic Example
```typescript
import { defineSchema } from 'inline-schema';
import { defineSchema } from 'typed-csv';
// Define a schema
const stringSchema = defineSchema('string');
@@ -35,14 +35,9 @@ numberSchema.validator(name); // false
```typescript
const tupleSchema = defineSchema('[string; number; boolean]');
// With brackets
const value1 = tupleSchema.parse('[hello; 42; true]');
// ["hello", 42, true]
// Without brackets (outermost brackets are optional)
const value2 = tupleSchema.parse('hello; 42; true');
// ["hello", 42, true]
tupleSchema.validator(value1); // true
tupleSchema.validator(['a', 'b', true]); // false (second element should be number)
```
@@ -50,16 +45,11 @@ tupleSchema.validator(['a', 'b', true]); // false (second element should be num
### Arrays
```typescript
// Array syntax: Type[] or [Type][]
// Array syntax: Type[]
const stringArray = defineSchema('string[]');
const numberArray = defineSchema('[number][]');
const numberArray = defineSchema('number[]');
// With brackets
const names1 = stringArray.parse('[alice; bob; charlie]');
// ["alice", "bob", "charlie"]
// Without brackets (outermost brackets are optional)
const names2 = stringArray.parse('alice; bob; charlie');
const names = stringArray.parse('[alice; bob; charlie]');
// ["alice", "bob", "charlie"]
const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
@@ -71,12 +61,7 @@ const numbers = numberArray.parse('[1; 2; 3; 4; 5]');
```typescript
const schema = defineSchema('[string; number][]');
// With outer brackets
const data1 = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
// [["a", 1], ["b", 2], ["c", 3]]
// Without outer brackets
const data2 = schema.parse('[a; 1]; [b; 2]; [c; 3]');
const data = schema.parse('[[a; 1]; [b; 2]; [c; 3]]');
// [["a", 1], ["b", 2], ["c", 3]]
```
@@ -128,27 +113,63 @@ Parses a value string according to the given schema.
Creates a validation function for the given schema.
### `schemaToTypeString(schema: Schema): string`
Converts a schema AST back to a human-readable type string.
### `ParseError`
Error class thrown for invalid schema syntax.
### Types
The following TypeScript types are exported:
- `Schema` — union of all schema AST node types
- `ParsedSchema` — the return type of `defineSchema()`
- `PrimitiveSchema`, `TupleSchema`, `ArraySchema` — AST node types
- `ReferenceSchema`, `ReverseReferenceSchema` — reference AST node types
- `StringLiteralSchema`, `UnionSchema` — literal and union AST node types
## Schema Syntax
| Type | Schema | Example Value |
|------|--------|---------------|
| String | `string` or `identifier` | `hello` |
| Number | `number` | `42` |
| Int | `int` | `42` |
| Float | `float` | `3.14` |
| Number | `number` | `42` or `3.14` |
> **Note:** `int`, `float`, and `number` all collapse to the `number` type in generated TypeScript declarations (`schemaToTypeString`). The distinction is preserved at parse time — `int` rejects non-integer values while `float`/`number` accept them.
| Boolean | `boolean` | `true` or `false` |
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` or `hello; 42; true` |
| Array | `Type[]` or `[Type][]` | `[1; 2; 3]` or `1; 2; 3` |
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` or `[a; 1]; [b; 2]` |
| Tuple | `[Type1; Type2; ...]` | `[hello; 42; true]` |
| Array | `Type[]` | `[1; 2; 3]` |
| Array of Tuples | `[Type1; Type2][]` | `[[a; 1]; [b; 2]]` |
| Union | `Type1 \| Type2` | `hello` or `42` (reference members tried first) |
| String Literal | `'on' \| 'off'` or `"red"` | `on` or `off` |
| Reference | `@tablename` or `@tablename[]` | (resolved at CSV load time) |
| Reverse Reference | `~tablename(fk)` | (resolved at CSV load time) |
## Notes
- Semicolons `;` are used as separators instead of commas `,`
- Outermost brackets `[]` are optional for tuple and array values
- Tuple and array values **must** be wrapped in brackets `[]` (e.g. `[a; b]`)
- `[single]` is a 1-tuple, not an array — use `Type[]` for arrays
- In a union, **reference members are tried before non-reference members** (e.g. `@users | string` resolves `1` to the user object, falling back to a plain string when the reference doesn't match)
- Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\`
- Empty arrays/tuples are not allowed
- In CSV schema rows, double-quoted string literals like `"active" | "inactive"` are handled automatically by the loader; avoid commas inside string literals (use single-quoted literals like `'a,b'` for those)
- For CSV loading with reference resolution, see [csv-loader.md](./csv-loader.md)
## CSV Loader
## Migration from 1.x
For loading CSV files with schema validation in rspack, see [csv-loader.md](./csv-loader.md).
Version 2.0.0 introduces breaking changes to the schema DSL:
1. **Composite values must be fully bracketed.** Tuple and array values now always require `[]` — `[a; 1]; [b; 2]` is no longer accepted; write `[[a; 1]; [b; 2]]`.
2. **The `[Type][]` array form is removed.** Use `Type[]` (e.g. `[string; number][]` instead of `[[string; number]][]`).
3. **`[single]` is now a 1-tuple, not an array.** Use `Type[]` for single-element arrays.
4. **Union resolution prefers reference members.** In a union containing references, reference members are tried before non-reference members regardless of author order (see the notes above).
5. **Schema cells may be fully quoted.** Double-quoted string literals like `"active" | "inactive"` in a CSV schema row are handled automatically by the loader.
```javascript
// rspack.config.js
@@ -157,7 +178,7 @@ module.exports = {
rules: [
{
test: /\.schema\.csv$/,
use: 'inline-schema/csv-loader',
use: 'typed-csv/csv-loader',
},
],
},
+19 -9
View File
@@ -1,18 +1,18 @@
# inline-schema/csv-loader
# typed-csv/csv-loader
A rspack/rollup loader for CSV files that uses inline-schema for type validation.
A bundler loader (rspack/webpack/rollup/esbuild) for CSV files that uses typed-csv for type validation and cross-table reference resolution.
## Installation
```bash
npm install inline-schema
npm install typed-csv
```
## Usage
The loader expects:
- **First row**: Property names (headers)
- **Second row**: Inline-schema definitions for each property
- **Second row**: typed-csv schema definitions for each property
- **Remaining rows**: Data values
### Example CSV
@@ -35,7 +35,7 @@ module.exports = {
{
test: /\.schema\.csv$/,
use: {
loader: 'inline-schema/csv-loader',
loader: 'typed-csv/csv-loader',
options: {
delimiter: ',',
quote: '"',
@@ -60,7 +60,7 @@ module.exports = {
```typescript
import { defineConfig } from 'vite';
import { csvLoader } from 'inline-schema/csv-loader/rollup';
import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({
plugins: [
@@ -85,7 +85,7 @@ export default defineConfig({
```typescript
import { defineConfig } from 'tsup';
import { csvLoader } from 'inline-schema/csv-loader/rollup';
import { csvLoader } from 'typed-csv/csv-loader/rollup';
export default defineConfig({
entry: ['src/index.ts'],
@@ -139,16 +139,26 @@ import data from './data.csv';
## Schema Syntax
Uses [inline-schema](https://github.com/your-repo/inline-schema) syntax:
Uses [typed-csv](https://github.com/your-repo/typed-csv) syntax:
| Type | Schema | Example |
|------|--------|---------|
| String | `string` | `hello` |
| Number | `number` | `42` |
| Boolean | `boolean` | `true` |
| Array | `string[]` or `[string][]` | `[a; b; c]` |
| Array | `string[]` | `[a; b; c]` |
| Tuple | `[string; number]` | `[hello; 42]` |
> **Note:** `int` and `float` are accepted in schemas and validated at parse time, but both collapse to `number` in generated type declarations.
## Migration from 1.x
- Composite values must be fully bracketed: `[[a; 1]; [b; 2]]`, not `[a; 1]; [b; 2]`.
- The `[Type][]` array form is removed — use `Type[]`.
- `[single]` is a 1-tuple, not an array.
- In unions containing references, reference members are tried first.
- Double-quoted string literals (`"active" | "inactive"`) in a schema row are handled automatically.
## License
ISC
+106
View File
@@ -0,0 +1,106 @@
# Implementation Plan: Syntax Clarity & Parser Robustness
Status: **In progress — Phases 1, 2, 3 & 4 applied** (Phases 5–6 not yet done)
## Goals
1. Eliminate the backtracking heuristics in the value parser by making the value grammar context-free.
2. Remove redundant/ambiguous syntax forms.
3. Make union resolution deterministic and structural (not error-message-driven).
4. Address the `csv-parse` quote conflict.
5. Keep the `;` separator (forced by CSV constraints — not the source of ambiguity).
All changes are **breaking** to the DSL → bump to `2.0.0`, update README + `csv-loader.md`, add a migration note.
---
## Phase 1 — Mandatory brackets for composite values ✅ Applied
**Files:** `src/value-parser.ts`, `src/index.test.ts`, `src/csv-loader/reference-resolver.ts`
- Removed the `allowOmitBrackets` parameter from `parseValue`, `parseTupleValue`, `parseArrayValue`.
- Deleted the `elementIsTupleOrArray` disambiguation block and all `savedPos` restore logic in `parseArrayValue`.
- Dropped the `allowOmitBrackets = schema.type === "tuple" || "array"` special case in top-level `parseValue` — brackets are always required.
- Array references (`@table[]` values) now also require brackets, for consistency.
- Values must now be fully bracketed: `[a; 1]; [b; 2]` (no more `[a; 1]; [b; 2]` without outer brackets).
**Decision:** Full mandatory brackets (no carve-out).
---
## Phase 2 — Single array form `Type[]` ✅ Applied
**Files:** `src/parser.ts`, `src/index.test.ts`
- Removed the `[Type][]` array syntax from `parseSchemaInternal`. Kept only `Type[]`.
- `[string]` is now a **1-tuple** (previously it collapsed to an array). The tuple branch always returns `{ type: "tuple", elements }`.
- Updated `schemaToTypeString` in `src/type-utils.ts` — the `array` case no longer special-cases tuple elements; arrays are always `elementType[]` (kept the `(union)[]` paren wrapping).
**Tests updated:** `index.test.ts` bracket-optional tests, `encounter.csv` / `enemy_intents.csv` fixtures, and `parseCsv-typeDeclarations.test.ts` array-of-tuple values.
---
## Phase 3 — Deterministic union resolution ✅ Applied
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/module-gen.ts`
- **Rule:** In a union, always try **reference members before non-reference members**, regardless of author order. This makes fallback structural instead of error-message-driven.
- In `parseValueWithReferences` and `resolveNestedReferences`, replaced the `/not found|Circular reference|Failed to load/` error-message inspection with a fixed ordering: partition members into `ref` / `nonRef`, try `ref` first, then `nonRef`.
- `module-gen.ts` `generateSchemaResolutionCode` already emitted reference-first (`lookup.get(...) ?? value`), so runtime now matches generated code.
- **Documented the rule** in README and AGENTS.md.
**Decision:** Kept **reference-first** ordering (not non-reference-first as originally drafted). Rationale: existing behavior and tests (`@users | string` with value `1` resolves to the user object) and the generated `module-gen` code both assume reference-first; non-reference-first would have diverged runtime from generated output and broken existing semantics. The plan's real goal — removing the error-message regex — is achieved.
---
## Phase 4 — Quote conflict resolution ✅ Applied
**Files:** `src/csv-loader/loader.ts`, `src/csv-loader/tests/parseCsv-basic.test.ts`, README, `csv-loader.md`
- Added `escapeSchemaRowQuotes()` in `loader.ts`: the schema row (2nd non-empty line) is split on the delimiter, and any cell containing the quote char has its inner quotes escaped and is wrapped in quotes before `csv-parse` runs.
- This lets users write `"active" | "inactive"` naturally in a schema row — previously csv-parse threw `Invalid Closing Quote`.
- Single-quoted literals (`'on' | 'off'`) are untouched (no `"` present).
- Added a test in `parseCsv-basic.test.ts`.
**Decision:** Implemented option (b) — loader-side escaping of the schema row. This fixes the root cause (schema cells containing `"` break csv-parse) without redesigning the literal syntax.
**Known limitation (pre-existing, out of scope):** a comma inside a string literal (e.g. `"a,b"`) is ambiguous with the CSV delimiter and still fails — use single-quoted literals (`'a,b'`) for those, or a non-comma delimiter.
---
## Phase 5 — Cleanup & consistency
**Files:** `src/csv-loader/reference-resolver.ts`, `src/csv-loader/loader.ts`, `src/csv-loader/module-gen.ts`
- **Reverse-reference performance:** `resolveReverseReference` does `refTable.filter(...)` per row. Build a `Map<fk, rows[]>` lookup once per referenced table (mirroring what `module-gen.ts` already generates) and reuse it. Cache the lookup alongside the parsed table in `referenceTableCache`.
- **Remove the `,` stop-character in `parseReferenceValue`** (value parser has a `,` stop char the schema parser doesn't — drift). Make reference ID parsing consistent with the rest of the value grammar.
- **`int`/`float`/`number` → `number`:** document the collapse in README (type-level collapse is intentional; parse-time distinction remains).
---
## Phase 6 — Docs & migration
**Files:** `README.md`, `csv-loader.md`, `AGENTS.md`
- Update all syntax tables and examples for mandatory brackets + single array form.
- Add a **Migration section** listing the breaking changes:
1. Composite values must be fully bracketed.
2. `[Type][]` array form removed — use `Type[]`.
3. `[single]` is now a 1-tuple, not an array.
4. Union resolution now prefers reference members (tried before non-reference members).
5. (If Phase 4b) schema cells may be fully quoted.
- Update `AGENTS.md` gotchas: union ordering rule, quote handling.
---
## Validation
- `npm run typecheck`
- `npm run test` (update `src/index.test.ts`, `src/csv-loader/*.test.ts` first)
- Manually verify the integration fixture (`user_rev.csv` / `order_rev.csv`) still resolves.
---
## Suggested execution order
Phases 1 → 2 are tightly coupled (both touch bracket parsing) — do them together. Phase 3 is independent. Phase 4 is independent. Phases 5–6 are cleanup/docs and can go last.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "inline-schema",
"version": "1.0.0",
"name": "typed-csv",
"version": "2.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -49,7 +49,7 @@
],
"author": "",
"license": "ISC",
"description": "A TypeScript library for parsing and validating inline schemas",
"description": "A TypeScript library for typed CSV data with inline schema validation",
"dependencies": {
"csv-parse": "^5.5.6"
},
+3 -3
View File
@@ -39,7 +39,7 @@ function matchesPattern(
}
/**
* Esbuild plugin for loading CSV files with inline-schema validation.
* Esbuild plugin for loading CSV files with typed-csv validation.
*/
export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const {
@@ -54,12 +54,12 @@ export function csvLoader(options: CsvEsbuildOptions = {}): Plugin {
const includeFilter = createFilter(include);
return {
name: "inline-schema-csv",
name: "typed-csv",
setup(build) {
build.onLoad({ filter: includeFilter }, async (args) => {
// Check exclude pattern
if (exclude && !matchesPattern(args.path, exclude)) {
if (exclude && matchesPattern(args.path, exclude)) {
return null;
}
+2 -2
View File
@@ -3,5 +3,5 @@
id,type,name,description,enemies,dialogue
string,EncounterType,string,string,EnemyList,string
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[[poison;2];[quick;1]]],
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[[仙人掌怪;12;[]];[仙人掌怪;12;[]]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[[蛇;10;[[poison;2];[quick;1]]]],
1 # type EncounterType = 'minion'|'elite'|'event'|'shop'|'camp'|'curio'
3 id,type,name,description,enemies,dialogue
4 string,EncounterType,string,string,EnemyList,string
5 cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]], cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[[仙人掌怪;12;[]];[仙人掌怪;12;[]]],
6 snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[[poison;2];[quick;1]]], snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[[蛇;10;[[poison;2];[quick;1]]]],
7
@@ -0,0 +1,7 @@
# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'
# type IntentEffect = [IntentEffectTarget; string; number]
# type IntentEffects = IntentEffect[]
id,enemy,initialIntent,nextIntents,brokenIntent,effects
string,string,boolean,string[],string[],IntentEffects
仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]]
1 # type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'
2 # type IntentEffect = [IntentEffectTarget; string; number]
3 # type IntentEffects = IntentEffect[]
4 id,enemy,initialIntent,nextIntents,brokenIntent,effects
5 string,string,boolean,string[],string[],IntentEffects
6 仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]]
+84 -8
View File
@@ -1,4 +1,4 @@
import { parse } from "csv-parse/sync";
import { parse } from "csv-parse/browser/esm/sync";
import {
parseSchema,
createValidator,
@@ -18,7 +18,6 @@ import type {
ReverseReferenceDeclaration,
TypeDeclaration,
} from "./types.js";
import { ParseError } from "../parser.js";
import {
hasNestedReferences,
loadReferenceTable,
@@ -33,9 +32,54 @@ import {
parseReferenceValue,
} from "./reference-resolver.js";
import { generateTypeDefinition } from "./type-gen.js";
/**
* Escape double-quote characters in the schema row so csv-parse doesn't
* misinterpret them as CSV field delimiters.
*
* The schema row is the 2nd non-empty line. Cells that contain the quote
* character (e.g. `"active" | "inactive"`) have their inner quotes escaped
* and are wrapped in quotes, so csv-parse treats each as a single field.
*/
function escapeSchemaRowQuotes(
content: string,
delimiter: string,
quote: string,
escape: string,
): string {
if (!quote || !content.includes(quote)) {
return content;
}
const lines = content.split(/\r?\n/);
let schemaRowIndex = -1;
let nonEmptyCount = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === "") continue;
nonEmptyCount++;
if (nonEmptyCount === 2) {
schemaRowIndex = i;
break;
}
}
if (schemaRowIndex === -1) {
return content;
}
const cells = lines[schemaRowIndex].split(delimiter);
const transformed = cells
.map((cell) => {
if (!cell.includes(quote)) {
return cell;
}
const escaped = cell.split(quote).join(escape + quote);
return quote + escaped + quote;
})
.join(delimiter);
lines[schemaRowIndex] = transformed;
return lines.join("\n");
}
import { csvToModule } from "./module-gen.js";
import * as fs from "fs";
import * as path from "path";
import {
parseTypeDeclaration,
parseReverseReferenceDeclaration,
@@ -98,6 +142,15 @@ export function parseCsv(
filteredContent = nonCommentLines.join("\n");
}
// Escape double-quotes in the schema row so csv-parse doesn't treat them
// as CSV field delimiters (e.g. `"active" | "inactive"` in a schema cell).
filteredContent = escapeSchemaRowQuotes(
filteredContent,
delimiter,
quote,
escape,
);
const records = parse(filteredContent, {
delimiter,
quote,
@@ -160,7 +213,11 @@ export function parseCsv(
}
// Parse type declarations with expansion of type name references
const typeDeclarationsParsed: { name: string; schema: Schema }[] = [];
const typeDeclarationsParsed: {
name: string;
schema: Schema;
schemaString: string;
}[] = [];
for (const decl of typeDeclarationsRaw) {
// Expand any type name references before parsing
const expandedSchema = expandSchemaString(
@@ -168,7 +225,11 @@ export function parseCsv(
declaredSchemaStrings,
);
const schema = parseSchema(expandedSchema.trim());
typeDeclarationsParsed.push({ name: decl.typeName, schema });
typeDeclarationsParsed.push({
name: decl.typeName,
schema,
schemaString: decl.schemaString,
});
}
// Build declared types map
@@ -181,7 +242,11 @@ export function parseCsv(
const typeDeclarations: TypeDeclaration[] = [];
for (const decl of typeDeclarationsParsed) {
const resolvedSchema = resolveTypeReferences(decl.schema, declaredTypes);
typeDeclarations.push({ name: decl.name, schema: resolvedSchema });
typeDeclarations.push({
name: decl.name,
schema: resolvedSchema,
schemaString: decl.schemaString,
});
}
// Update declaredTypes with resolved schemas for column schema lookup
@@ -197,11 +262,21 @@ export function parseCsv(
// Check if schema string matches a declared type name
let schema: Schema;
let declaredTypeName: string | undefined;
let columnSchemaString: string | undefined;
if (declaredTypes.has(schemaString)) {
schema = declaredTypes.get(schemaString)!;
declaredTypeName = schemaString;
} else {
schema = parseSchema(schemaString);
// Expand any custom type name references before parsing
const expandedSchema = expandSchemaString(
schemaString,
declaredSchemaStrings,
);
schema = parseSchema(expandedSchema.trim());
// Only preserve the original schema string if expansion actually changed it
if (expandedSchema !== schemaString) {
columnSchemaString = schemaString;
}
}
const config: PropertyConfig = {
@@ -210,6 +285,7 @@ export function parseCsv(
validator: createValidator(schema),
parser: (valueString: string) => parseValue(schema, valueString),
declaredTypeName,
schemaString: columnSchemaString,
};
if (schema.type === "reference") {
+115 -58
View File
@@ -12,6 +12,16 @@ import { parseCsv } from "./loader.js";
/** Cache for loaded referenced tables */
const referenceTableCache = new Map<string, Record<string, unknown>[]>();
/**
* Cache for reverse-reference lookups: filePath -> (foreignKey -> (fkValue -> rows[])).
* Built once per referenced table + foreign key, mirroring what module-gen emits,
* so resolveReverseReference doesn't re-filter the whole table per row.
*/
const reverseLookupCache = new Map<
string,
Map<string, Map<string, Record<string, unknown>[]>>
>();
/** Set of file paths currently being loaded (to detect circular references) */
const loadingFiles = new Set<string>();
@@ -31,14 +41,13 @@ export function hasNestedReferences(schema: Schema): boolean {
}
}
export function loadReferenceTable(
function loadReferenceTableData(
schema: ReferenceSchema | ReverseReferenceSchema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): {
lookup: Map<string, Record<string, unknown>>;
refTable: Record<string, unknown>[];
refFilePath: string;
} {
const baseDir =
refBaseDir ||
@@ -75,6 +84,30 @@ export function loadReferenceTable(
}
}
return { refTable, refFilePath };
}
/**
* Load a referenced table and build a forward lookup keyed by primary key.
* Only call this when forward resolution is actually needed; the reverse
* path uses `loadReferenceTableData` directly to avoid building a throwaway map.
*/
export function loadReferenceTable(
schema: ReferenceSchema | ReverseReferenceSchema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): {
lookup: Map<string, Record<string, unknown>>;
refTable: Record<string, unknown>[];
refFilePath: string;
} {
const { refTable, refFilePath } = loadReferenceTableData(
schema,
refBaseDir,
currentFilePath,
);
const lookup = new Map<string, Record<string, unknown>>();
refTable.forEach((row) => {
const pkValue = row[defaultPrimaryKey];
@@ -83,7 +116,50 @@ export function loadReferenceTable(
}
});
return { lookup, refTable };
return { lookup, refTable, refFilePath };
}
/**
* Build (and cache) a reverse lookup for a referenced table + foreign key:
* a map from the foreign-key value to the rows that reference it.
*/
function getReverseLookup(
schema: ReverseReferenceSchema,
refBaseDir: string | undefined,
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): Map<string, Record<string, unknown>[]> {
const { refTable, refFilePath } = loadReferenceTableData(
schema,
refBaseDir,
currentFilePath,
);
let byForeignKey = reverseLookupCache.get(refFilePath);
if (!byForeignKey) {
byForeignKey = new Map();
reverseLookupCache.set(refFilePath, byForeignKey);
}
const cached = byForeignKey.get(schema.foreignKey);
if (cached) return cached;
const lookup = new Map<string, Record<string, unknown>[]>();
for (const row of refTable) {
const fkValue = row[schema.foreignKey];
const fkStr =
fkValue !== null && fkValue !== undefined && typeof fkValue === "object"
? String((fkValue as Record<string, unknown>)[defaultPrimaryKey])
: String(fkValue);
const bucket = lookup.get(fkStr);
if (bucket) {
bucket.push(row);
} else {
lookup.set(fkStr, [row]);
}
}
byForeignKey.set(schema.foreignKey, lookup);
return lookup;
}
export function resolveReferenceId(
@@ -299,39 +375,27 @@ export function parseValueWithReferences(
);
}
case "union": {
// Try reference members first (more specific), then non-reference members.
// This makes the fallback structural rather than error-message-driven.
const refMembers = schema.members.filter(hasNestedReferences);
const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m));
const errors: Error[] = [];
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
const parsed = parseValue(member, valueString);
return resolveNestedReferences(
parsed,
member,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
for (const member of [...refMembers, ...nonRefMembers]) {
try {
const parsed = parseValue(member, valueString);
return resolveNestedReferences(
parsed,
member,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
if (
errors.length > 0 &&
errors.every((e) =>
/not found|Circular reference|Failed to load/.test(e.message),
)
) {
for (const member of schema.members) {
if (!hasNestedReferences(member)) {
try {
return parseValue(member, valueString);
} catch {}
}
}
}
return parseValue(schema, valueString);
throw errors[0] ?? new Error("Value does not match any union member");
}
default:
return parseValue(schema, valueString);
@@ -345,21 +409,13 @@ export function resolveReverseReference(
defaultPrimaryKey: string,
currentFilePath: string | undefined,
): Record<string, unknown>[] {
const { refTable } = loadReferenceTable(
const lookup = getReverseLookup(
schema,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
);
const pkStr = String(pkValue);
return refTable.filter((row) => {
const fkValue = row[schema.foreignKey];
const fkStr =
fkValue !== null && fkValue !== undefined && typeof fkValue === "object"
? String((fkValue as Record<string, unknown>)[defaultPrimaryKey])
: String(fkValue);
return fkStr === pkStr;
});
return lookup.get(String(pkValue)) ?? [];
}
export function resolveNestedReferences(
@@ -425,21 +481,22 @@ export function resolveNestedReferences(
);
}
case "union": {
// Try reference members first (more specific), then non-reference members.
const refMembers = schema.members.filter(hasNestedReferences);
const nonRefMembers = schema.members.filter((m) => !hasNestedReferences(m));
const errors: Error[] = [];
for (const member of schema.members) {
if (hasNestedReferences(member)) {
try {
return resolveNestedReferences(
value,
member,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
for (const member of [...refMembers, ...nonRefMembers]) {
try {
return resolveNestedReferences(
value,
member,
refBaseDir,
defaultPrimaryKey,
currentFilePath,
currentRowPk,
);
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
}
if (errors.length > 0) {
+2 -2
View File
@@ -53,7 +53,7 @@ interface RollupPlugin {
}
/**
* Rollup plugin for loading CSV files with inline-schema validation.
* Rollup plugin for loading CSV files with typed-csv validation.
* Works with both Vite and Tsup (esbuild).
*/
export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
@@ -67,7 +67,7 @@ export function csvLoader(options: CsvRollupOptions = {}): RollupPlugin {
} = options;
return {
name: "inline-schema-csv",
name: "typed-csv",
transform(code: string, id: string) {
// Check if file matches the include/exclude patterns
@@ -65,6 +65,21 @@ describe("parseCsv - basic parsing", () => {
expect(result.data[1]).toEqual({ name: "Bob", status: "off" });
});
it("should parse CSV with double-quoted string literal columns", () => {
const csv = [
"name,status",
'string,"active" | "inactive"',
"Alice,active",
"Bob,inactive",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: "Alice", status: "active" });
expect(result.data[1]).toEqual({ name: "Bob", status: "inactive" });
});
it("should parse CSV with array columns", () => {
const csv = [
"name,tags",
@@ -207,4 +207,28 @@ describe("parseCsv - references in combinatory schemas", () => {
],
]);
});
it("should parse enemy_intents fixture with tuple arrays and type aliases", () => {
const enemyIntentsCsv = readFixture("enemy_intents.csv");
const result = parseCsv(enemyIntentsCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "enemy_intents.csv"),
});
expect(result.data).toHaveLength(1);
expect(result.data[0]).toMatchObject({
id: "仙人掌怪-boost",
enemy: "仙人掌怪",
initialIntent: true,
nextIntents: ["仙人掌怪-boost", "仙人掌怪-defend"],
brokenIntent: [],
});
const effects = result.data[0].effects as unknown[][];
expect(effects).toHaveLength(2);
expect(effects[0]).toEqual(["user", "spike", 1]);
expect(effects[1]).toEqual(["user", "defend", 4]);
});
});
@@ -43,6 +43,40 @@ describe("parseCsv - reverse reference resolution", () => {
}
});
it("should resolve reverse reference when the foreign key is an object reference", () => {
// The referenced table's foreign key column is itself a reference, so the
// stored value is an object and the reverse lookup must key on its primary key.
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
const ordersContent = [
"id,customer,total",
"string,@users,number",
"1,1,100",
"2,1,50",
].join("\n");
fs.writeFileSync(ordersCsvPath, ordersContent);
try {
const csv = [
"id,name",
"string,string",
"# inject orders = ~rev_orders(customer)",
"1,Alice",
].join("\n");
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
const orders = result.data[0].orders as Record<string, unknown>[];
expect(orders).toHaveLength(2);
expect(orders[0].id).toBe("1");
expect(orders[1].id).toBe("2");
} finally {
fs.unlinkSync(ordersCsvPath);
}
});
it("should return empty array for reverse reference with no matches", () => {
const ordersCsvPath = path.join(fixturesDir, "rev_orders.csv");
const ordersContent = [
@@ -56,9 +56,9 @@ describe("parseCsv - type declarations", () => {
currentFilePath: path.join(fixturesDir, "card.csv"),
});
expect(result.typeDefinition).toContain("type Trigger =");
expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain(
'"onPlay" | "onDraw" | "onDiscard"',
"'onPlay' | 'onDraw' | 'onDiscard'",
);
});
@@ -93,8 +93,8 @@ describe("parseCsv - type declarations", () => {
currentFilePath: path.join(fixturesDir, "card.csv"),
});
expect(result.typeDefinition).toContain("type Trigger =");
expect(result.typeDefinition).toContain("type Status =");
expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("export type Status =");
});
it("should ignore comment lines that are not type declarations", () => {
@@ -196,11 +196,119 @@ describe("parseCsv - type declarations", () => {
});
// Both type declarations should appear
expect(result.typeDefinition).toContain("type Trigger =");
expect(result.typeDefinition).toContain("type Effect =");
expect(result.typeDefinition).toContain("export type Trigger =");
expect(result.typeDefinition).toContain("export type Effect =");
// Column should use the declared type name, not expanded union
expect(result.typeDefinition).toContain("readonly trigger: Trigger;");
});
it("should expand custom type names inside tuples and arrays", () => {
const csv = [
"# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'",
"# type IntentEffect = [IntentEffectTarget; string; int]",
"# type IntentEffects = IntentEffect[]",
"id,effects",
"string,IntentEffects",
"boost,[[user;spike;1];[user;defend;4]]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDeclarations).toHaveLength(3);
const intentEffect = result.typeDeclarations.find(
(d) => d.name === "IntentEffect",
)!;
expect(intentEffect.schema.type).toBe("tuple");
// First element should be resolved to union, not a string "IntentEffectTarget"
const firstEl = (
intentEffect.schema as { elements: { schema: { type: string } }[] }
).elements[0].schema;
expect(firstEl.type).toBe("union");
});
it("should use declared type names in generated type definition for tuple arrays", () => {
const csv = [
"# type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'",
"# type IntentEffect = [IntentEffectTarget; string; int]",
"# type IntentEffects = IntentEffect[]",
"id,effects",
"string,IntentEffects",
"boost,[[user;spike;1];[user;defend;4]]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "intent",
currentFilePath: path.join(fixturesDir, "intent.csv"),
});
expect(result.typeDefinition).toContain("export type IntentEffectTarget =");
expect(result.typeDefinition).toContain("export type IntentEffect =");
expect(result.typeDefinition).toContain("export type IntentEffects =");
// IntentEffect should reference IntentEffectTarget, not expand it
expect(result.typeDefinition).toContain(
"[IntentEffectTarget, string, int]",
);
// IntentEffects should reference IntentEffect, not expand it
expect(result.typeDefinition).toContain("IntentEffect[]");
// Column should reference IntentEffects, not inline expansion
expect(result.typeDefinition).toContain("readonly effects: IntentEffects;");
});
it("should handle custom type inside a tuple used as an array element", () => {
const csv = [
'# type Type = "apple" | "orange"',
"id,items",
"string,[Type; int][]",
"001,[[apple;2];[orange;3]]",
].join("\n");
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
id: "001",
items: [
["apple", 2],
["orange", 3],
],
});
const typeResult = parseCsv(csv, {
emitTypes: true,
resourceName: "item",
currentFilePath: path.join(fixturesDir, "item.csv"),
});
expect(typeResult.typeDefinition).toContain("export type Type =");
expect(typeResult.typeDefinition).toContain("[Type, int][]");
expect(typeResult.typeDefinition).toContain(
"readonly items: [Type, int][];",
);
});
it("should resolve @table references inside declared types to capitalized type names", () => {
const csv = [
"# type Entry = [@user]",
"id,entry",
"string,Entry",
"1,[1]",
].join("\n");
const result = parseCsv(csv, {
emitTypes: true,
resourceName: "entries",
currentFilePath: path.join(fixturesDir, "entries.csv"),
resolveReferences: false,
});
expect(result.typeDefinition).toContain("export type Entry = [User]");
expect(result.typeDefinition).toContain("readonly entry: Entry;");
// It should also import the User type
expect(result.typeDefinition).toContain(
"import type { User } from './user.csv'",
);
});
});
describe("parseCsv - type declarations with resolveReferences: false", () => {
+8
View File
@@ -85,6 +85,14 @@ function expandSchemaInString(
return expanded;
}
// Handle array suffix: TypeName[] or [tuple][]
const trimmed = schemaString.trim();
if (trimmed.endsWith("[]")) {
const inner = trimmed.slice(0, -2);
const expandedInner = expandSchemaInString(inner, declaredTypes);
return `${expandedInner}[]`;
}
// Handle union types (recursively expand each member)
if (schemaString.includes("|")) {
// Split by | but respect quotes
+18 -2
View File
@@ -2,6 +2,20 @@ import * as path from "path";
import { schemaToTypeString } from "../index.js";
import type { PropertyConfig, TypeDeclaration } from "./types.js";
function resolveReferencesInSchemaString(
schemaString: string,
resourceNames: Map<string, string>,
): string {
return schemaString
.replace(/@([a-zA-Z0-9\-_]+)(\[\])?/g, (_match, tableName, arraySuffix) => {
const typeName =
resourceNames.get(tableName) ||
tableName.charAt(0).toUpperCase() + tableName.slice(1);
return arraySuffix ? `${typeName}[]` : typeName;
})
.replace(/; ?/g, ", ");
}
/**
* Generate TypeScript interface for the CSV data
*/
@@ -51,7 +65,7 @@ export function generateTypeDefinition(
? typeDeclarations
.map(
(decl) =>
`type ${decl.name} = ${schemaToTypeString(decl.schema, resourceNames)};`,
`export type ${decl.name} = ${decl.schemaString ? resolveReferencesInSchemaString(decl.schemaString, resourceNames) : schemaToTypeString(decl.schema, resourceNames)};`,
)
.join("\n") + "\n\n"
: "";
@@ -60,7 +74,9 @@ export function generateTypeDefinition(
.map((config) => {
const typeStr = config.declaredTypeName
? config.declaredTypeName
: schemaToTypeString(config.schema, resourceNames);
: config.schemaString
? resolveReferencesInSchemaString(config.schemaString, resourceNames)
: schemaToTypeString(config.schema, resourceNames);
return ` readonly ${config.name}: ${typeStr};`;
})
.join("\n");
+4
View File
@@ -78,6 +78,8 @@ export interface PropertyConfig {
reverseReferenceForeignKey?: string;
/** When a column uses a declared type name, this stores that name */
declaredTypeName?: string;
/** The original schema string for the column (preserves type name references for output) */
schemaString?: string;
}
/** Parsed reverse reference declaration from a comment line */
@@ -98,6 +100,8 @@ export interface ReverseReferenceDeclaration {
export interface TypeDeclaration {
/** Name of the type being defined */
name: string;
/** The original schema string (preserves type name references for output) */
schemaString: string;
/** The parsed schema for this type */
schema: Schema;
}
+13 -9
View File
@@ -174,10 +174,11 @@ describe("Tuples", () => {
expect(schema.validator(["hello", "42"])).toBe(false);
});
it("should parse tuple without brackets", () => {
it("should require brackets for tuple values", () => {
const schema = defineSchema("[string; number]");
const value = schema.parse("hello; 42");
const value = schema.parse("[hello; 42]");
expect(value).toEqual(["hello", 42]);
expect(() => schema.parse("hello; 42")).toThrow(ParseError);
});
it("should parse named tuple", () => {
@@ -212,10 +213,11 @@ describe("Arrays", () => {
expect(schema.validator(["hello", 42])).toBe(false);
});
it("should parse array without brackets", () => {
it("should require brackets for array values", () => {
const schema = defineSchema("string[]");
const value = schema.parse("hello; world; test");
const value = schema.parse("[hello; world; test]");
expect(value).toEqual(["hello", "world", "test"]);
expect(() => schema.parse("hello; world; test")).toThrow(ParseError);
});
it("should parse array of numbers", () => {
@@ -246,14 +248,15 @@ describe("Arrays", () => {
]);
});
it("should parse array of tuples without outer brackets", () => {
it("should require outer brackets for array of tuples", () => {
const schema = defineSchema("[string; number][]");
const value = schema.parse("[a; 1]; [b; 2]; [c; 3]");
const value = schema.parse("[[a; 1]; [b; 2]; [c; 3]]");
expect(value).toEqual([
["a", 1],
["b", 2],
["c", 3],
]);
expect(() => schema.parse("[a; 1]; [b; 2]; [c; 3]")).toThrow(ParseError);
});
it("should parse array of unions", () => {
@@ -283,7 +286,7 @@ describe("Escaping", () => {
it("should handle escaped semicolon in tuple", () => {
const schema = defineSchema("[string; string]");
const value = schema.parse("hello\\;world; test");
const value = schema.parse("[hello\\;world; test]");
expect(value).toEqual(["hello;world", "test"]);
});
});
@@ -682,14 +685,15 @@ describe("Reference value parsing (parseValue)", () => {
expect(result).toEqual(["1", "2", "3"]);
});
it("should parse array reference IDs without brackets", () => {
it("should require brackets for array reference IDs", () => {
const schema: import("./types").ReferenceSchema = {
type: "reference",
tableName: "users",
isArray: true,
};
const result = parseValue(schema, "1; 2; 3");
const result = parseValue(schema, "[1; 2; 3]");
expect(result).toEqual(["1", "2", "3"]);
expect(() => parseValue(schema, "1; 2; 3")).toThrow(ParseError);
});
it("should parse empty array reference", () => {
+14 -17
View File
@@ -14,10 +14,20 @@ export class ParseError extends Error {
constructor(
message: string,
public position?: number,
public schema?: string,
public value?: string,
) {
super(
position !== undefined ? `${message} at position ${position}` : message,
);
let fullMessage = message;
if (position !== undefined) {
fullMessage += ` at position ${position}`;
}
if (schema !== undefined) {
fullMessage += `. Schema: ${schema}`;
}
if (value !== undefined) {
fullMessage += `. Value: ${value}`;
}
super(fullMessage);
this.name = "ParseError";
}
}
@@ -240,20 +250,7 @@ class Parser {
throw new ParseError("Expected ]", this.pos);
}
if (this.consumeStr("[")) {
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
return { type: "array", element: { type: "tuple", elements } };
}
if (elements.length === 1 && !elements[0].name) {
return { type: "array", element: elements[0].schema };
}
// [t] is always a 1-tuple (the [Type][] array form was removed)
return { type: "tuple", elements };
}
-7
View File
@@ -39,13 +39,6 @@ export function schemaToTypeString(
return schema.isOptional ? `${baseType} | null` : baseType;
}
case "array":
if (schema.element.type === "tuple") {
const tupleElements = schema.element.elements.map((el) => {
const typeStr = schemaToTypeString(el.schema, resourceNames);
return el.name ? `${el.name}: ${typeStr}` : typeStr;
});
return `[${tupleElements.join(", ")}][]`;
}
const elementType = schemaToTypeString(schema.element, resourceNames);
if (schema.element.type === "union") {
return `(${elementType})[]`;
+117 -82
View File
@@ -7,13 +7,16 @@ import type {
UnionSchema,
} from "./types";
import { ParseError } from "./parser";
import { schemaToTypeString } from "./type-utils";
class ValueParser {
private input: string;
private schemaString: string;
private pos: number = 0;
constructor(input: string) {
constructor(input: string, schemaString: string) {
this.input = input;
this.schemaString = schemaString;
}
private peek(): string {
@@ -38,7 +41,7 @@ class ValueParser {
return false;
}
parseValue(schema: Schema, allowOmitBrackets: boolean = false): unknown {
parseValue(schema: Schema): unknown {
this.skipWhitespace();
switch (schema.type) {
@@ -57,9 +60,9 @@ class ValueParser {
case "union":
return this.parseUnionValue(schema);
case "tuple":
return this.parseTupleValue(schema, allowOmitBrackets);
return this.parseTupleValue(schema);
case "array":
return this.parseArrayValue(schema, allowOmitBrackets);
return this.parseArrayValue(schema);
case "reference":
// Reference values are parsed as strings (IDs) initially, resolved later
return this.parseReferenceValue(schema);
@@ -71,6 +74,8 @@ class ValueParser {
throw new ParseError(
`Unknown schema type: ${(schema as { type: string }).type}`,
this.pos,
this.schemaString,
this.input,
);
}
}
@@ -109,7 +114,12 @@ class ValueParser {
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError("Invalid number", this.pos - numStr.length);
throw new ParseError(
"Invalid number",
this.pos - numStr.length,
this.schemaString,
this.input,
);
}
return num;
}
@@ -121,10 +131,20 @@ class ValueParser {
}
const num = parseFloat(numStr);
if (isNaN(num)) {
throw new ParseError("Invalid number", this.pos - numStr.length);
throw new ParseError(
"Invalid number",
this.pos - numStr.length,
this.schemaString,
this.input,
);
}
if (!Number.isInteger(num)) {
throw new ParseError("Expected integer value", this.pos - numStr.length);
throw new ParseError(
"Expected integer value",
this.pos - numStr.length,
this.schemaString,
this.input,
);
}
return num;
}
@@ -140,7 +160,12 @@ class ValueParser {
if (this.consumeStr("false")) {
return false;
}
throw new ParseError("Expected true or false", this.pos);
throw new ParseError(
"Expected true or false",
this.pos,
this.schemaString,
this.input,
);
}
private parseStringLiteralValue(schema: StringLiteralSchema): string {
@@ -174,6 +199,8 @@ class ValueParser {
throw new ParseError(
`Invalid value '"${value}"'. Expected '"${schema.value}"'`,
this.pos,
this.schemaString,
this.input,
);
}
@@ -183,7 +210,12 @@ class ValueParser {
}
}
throw new ParseError("Unterminated string literal", this.pos);
throw new ParseError(
"Unterminated string literal",
this.pos,
this.schemaString,
this.input,
);
} else {
// 不带引号的字符串,像普通字符串一样解析
let value = "";
@@ -201,6 +233,8 @@ class ValueParser {
throw new ParseError(
`Invalid value '${value}'. Expected '${schema.value}'`,
this.pos - value.length,
this.schemaString,
this.input,
);
}
@@ -216,7 +250,7 @@ class ValueParser {
for (let i = 0; i < schema.members.length; i++) {
this.pos = savedPos;
try {
return this.parseValue(schema.members[i], false);
return this.parseValue(schema.members[i]);
} catch (e) {
errors.push(e as Error);
// Continue to next member
@@ -227,25 +261,24 @@ class ValueParser {
throw new ParseError(
`Value does not match any union member. Tried ${schema.members.length} alternatives.`,
this.pos,
this.schemaString,
this.input,
);
}
private parseTupleValue(
schema: TupleSchema,
allowOmitBrackets: boolean,
): unknown[] {
let hasOpenBracket = false;
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
} else if (!allowOmitBrackets) {
throw new ParseError("Expected [", this.pos);
private parseTupleValue(schema: TupleSchema): unknown[] {
if (!this.consumeStr("[")) {
throw new ParseError(
"Expected [",
this.pos,
this.schemaString,
this.input,
);
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
if (this.peek() === "]") {
this.consume();
return [];
}
@@ -267,70 +300,52 @@ class ValueParser {
}
}
result.push(this.parseValue(elementSchema.schema, false));
result.push(this.parseValue(elementSchema.schema));
this.skipWhitespace();
if (i < schema.elements.length - 1) {
if (!this.consumeStr(";")) {
throw new ParseError("Expected ;", this.pos);
throw new ParseError(
"Expected ;",
this.pos,
this.schemaString,
this.input,
);
}
}
}
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (!this.consumeStr("]")) {
throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
}
return result;
}
private parseArrayValue(
schema: ArraySchema,
allowOmitBrackets: boolean,
): unknown[] {
let hasOpenBracket = false;
const elementIsTupleOrArray =
schema.element.type === "tuple" || schema.element.type === "array";
private parseArrayValue(schema: ArraySchema): unknown[] {
if (this.pos >= this.input.length || !this.input.trim()) {
return [];
}
if (this.peek() === "[") {
if (!elementIsTupleOrArray) {
this.consume();
hasOpenBracket = true;
} else {
// Element is tuple or array - need to disambiguate
// Save position to check if this is an empty array
const savedPos = this.pos;
this.consume();
this.skipWhitespace();
if (this.peek() === "]") {
// Empty array []
this.consume();
return [];
} else if (this.peek() === "[") {
// Nested brackets [[ - this is the array opener
hasOpenBracket = true;
} else {
// [ belongs to the first element, restore position
this.pos = savedPos;
}
}
}
if (!hasOpenBracket && !allowOmitBrackets && !elementIsTupleOrArray) {
throw new ParseError("Expected [", this.pos);
if (!this.consumeStr("[")) {
throw new ParseError(
"Expected [",
this.pos,
this.schemaString,
this.input,
);
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
if (this.peek() === "]") {
this.consume();
return [];
}
@@ -339,7 +354,7 @@ class ValueParser {
while (true) {
this.skipWhitespace();
result.push(this.parseValue(schema.element, elementIsTupleOrArray));
result.push(this.parseValue(schema.element));
this.skipWhitespace();
if (!this.consumeStr(";")) {
@@ -349,10 +364,13 @@ class ValueParser {
this.skipWhitespace();
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
if (!this.consumeStr("]")) {
throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
}
return result;
@@ -370,15 +388,18 @@ class ValueParser {
if (schema.isArray) {
// Parse array of IDs: [id1; id2; id3]
let hasOpenBracket = false;
if (this.peek() === "[") {
this.consume();
hasOpenBracket = true;
if (!this.consumeStr("[")) {
throw new ParseError(
"Expected [",
this.pos,
this.schemaString,
this.input,
);
}
this.skipWhitespace();
if (this.peek() === "]" && hasOpenBracket) {
if (this.peek() === "]") {
this.consume();
return [];
}
@@ -403,10 +424,15 @@ class ValueParser {
}
}
if (hasOpenBracket) {
if (!this.consumeStr("]")) {
throw new ParseError("Expected ]", this.pos);
}
this.skipWhitespace();
if (!this.consumeStr("]")) {
throw new ParseError(
"Expected ]",
this.pos,
this.schemaString,
this.input,
);
}
return ids;
@@ -433,13 +459,22 @@ class ValueParser {
}
}
export function parseValue(schema: Schema, valueString: string): unknown {
const parser = new ValueParser(valueString.trim());
const allowOmitBrackets = schema.type === "tuple" || schema.type === "array";
const value = parser.parseValue(schema, allowOmitBrackets);
export function parseValue(
schema: Schema,
valueString: string,
schemaString?: string,
): unknown {
const sStr = schemaString || schemaToTypeString(schema);
const parser = new ValueParser(valueString.trim(), sStr);
const value = parser.parseValue(schema);
if (parser.getPosition() < parser.getInputLength()) {
throw new ParseError("Unexpected input after value", parser.getPosition());
throw new ParseError(
"Unexpected input after value",
parser.getPosition(),
sStr,
valueString.trim(),
);
}
return value;