From c969c7f6fc8e9af915c510ae122e84aebfd221bb Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 6 Aug 2026 09:19:39 +0800 Subject: [PATCH] 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. --- AGENTS.md | 1 + README.md | 32 ++--- csv-loader.md | 2 +- docs/syntax-rework-plan.md | 31 ++--- src/csv-loader/fixtures/encounter.csv | 4 +- src/csv-loader/fixtures/enemy_intents.csv | 2 +- .../tests/parseCsv-typeDeclarations.test.ts | 8 +- src/index.test.ts | 22 +-- src/parser.ts | 15 +- src/type-utils.ts | 7 - src/value-parser.ts | 129 ++++++------------ 11 files changed, 89 insertions(+), 164 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5fa4a2a..a8c0431 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 4f12adb..5892769 100644 --- a/README.md +++ b/README.md @@ -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]] ``` @@ -155,9 +140,9 @@ The following TypeScript types are exported: | Float | `float` | `3.14` | | Number | `number` | `42` or `3.14` | | 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` (matches first valid member) | | String Literal | `'on' \| 'off'` or `"red"` | `on` or `off` | | Reference | `@tablename` or `@tablename[]` | (resolved at CSV load time) | @@ -166,7 +151,8 @@ The following TypeScript types are exported: ## 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 - Special characters can be escaped with backslash: `\;`, `\[`, `\]`, `\\` - Empty arrays/tuples are not allowed - For CSV loading with reference resolution, see [csv-loader.md](./csv-loader.md) diff --git a/csv-loader.md b/csv-loader.md index 13c523b..253acfc 100644 --- a/csv-loader.md +++ b/csv-loader.md @@ -146,7 +146,7 @@ Uses [typed-csv](https://github.com/your-repo/typed-csv) syntax: | 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]` | ## License diff --git a/docs/syntax-rework-plan.md b/docs/syntax-rework-plan.md index 6c3b255..7db8964 100644 --- a/docs/syntax-rework-plan.md +++ b/docs/syntax-rework-plan.md @@ -1,6 +1,6 @@ # Implementation Plan: Syntax Clarity & Parser Robustness -Status: **Draft — not yet applied** +Status: **In progress — Phases 1 & 2 applied** (Phases 3–6 not yet done) ## Goals @@ -14,34 +14,29 @@ All changes are **breaking** to the DSL → bump to `2.0.0`, update README + `cs --- -## Phase 1 — Mandatory brackets for composite values +## Phase 1 — Mandatory brackets for composite values ✅ Applied **Files:** `src/value-parser.ts`, `src/index.test.ts`, `src/csv-loader/reference-resolver.ts` -- Remove the `allowOmitBrackets` parameter from `parseValue`, `parseTupleValue`, `parseArrayValue`. -- Delete the `elementIsTupleOrArray` disambiguation block and all `savedPos` restore logic in `parseArrayValue`. -- In `parseValue` (top-level), drop the `allowOmitBrackets = schema.type === "tuple" || "array"` special case — brackets are always required. -- Values now must be fully bracketed: `[a; 1]; [b; 2]` (no more `[a; 1]; [b; 2]` without outer brackets). +- 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). -**Tests to update** (currently assert bracket-optional behavior): -- `index.test.ts`: "should parse tuple without brackets" [L177], "should parse array without brackets" [L215], "should parse array of tuples without outer brackets" [L249]. -- `reference-resolver.ts` / `index.test.ts` reference tests: "should parse array reference IDs without brackets" [L685]. - -**Decision point:** Full mandatory brackets, or a *top-level-only* carve-out (brackets optional only when the value is the whole cell and the element is non-composite)? Recommend **full mandatory** for a clean grammar; note the carve-out as a fallback if ergonomics matter more. +**Decision:** Full mandatory brackets (no carve-out). --- -## Phase 2 — Single array form `Type[]` +## Phase 2 — Single array form `Type[]` ✅ Applied **Files:** `src/parser.ts`, `src/index.test.ts` -- Remove the `[Type][]` array syntax from `parseSchemaInternal`. Keep only `Type[]`. -- This makes `[string]` a **1-tuple** (currently it collapses to an array). Update the tuple branch: `[t]` → `{ type: "tuple", elements: [t] }`; the special case `elements.length === 1 && !elements[0].name` becomes a real tuple. -- Update `schemaToTypeString` in `src/type-utils.ts` — the `array` case currently special-cases tuple elements; with `[Type][]` gone, arrays are always `elementType[]` (keep the `(union)[]` paren wrapping). +- 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 to update:** -- `index.test.ts`: "should parse array of tuples" [L239] uses `[string; number][]` → becomes `[string; number][]` (unchanged) but "array of tuples without outer brackets" [L249] changes per Phase 1. -- Any test using `[number][]`, `[string][]` schema strings (e.g. README examples, `csv-loader.md`). +**Tests updated:** `index.test.ts` bracket-optional tests, `encounter.csv` / `enemy_intents.csv` fixtures, and `parseCsv-typeDeclarations.test.ts` array-of-tuple values. --- diff --git a/src/csv-loader/fixtures/encounter.csv b/src/csv-loader/fixtures/encounter.csv index a12f429..d9b7322 100644 --- a/src/csv-loader/fixtures/encounter.csv +++ b/src/csv-loader/fixtures/encounter.csv @@ -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]]]], diff --git a/src/csv-loader/fixtures/enemy_intents.csv b/src/csv-loader/fixtures/enemy_intents.csv index c1a7cf0..76ec5f0 100644 --- a/src/csv-loader/fixtures/enemy_intents.csv +++ b/src/csv-loader/fixtures/enemy_intents.csv @@ -4,4 +4,4 @@ id,enemy,initialIntent,nextIntents,brokenIntent,effects string,string,boolean,string[],string[],IntentEffects -仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[user;spike;1];[user;defend;4] +仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]] diff --git a/src/csv-loader/tests/parseCsv-typeDeclarations.test.ts b/src/csv-loader/tests/parseCsv-typeDeclarations.test.ts index 50545da..3a05231 100644 --- a/src/csv-loader/tests/parseCsv-typeDeclarations.test.ts +++ b/src/csv-loader/tests/parseCsv-typeDeclarations.test.ts @@ -209,7 +209,7 @@ describe("parseCsv - type declarations", () => { "# type IntentEffects = IntentEffect[]", "id,effects", "string,IntentEffects", - "boost,[user;spike;1];[user;defend;4]", + "boost,[[user;spike;1];[user;defend;4]]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); @@ -233,7 +233,7 @@ describe("parseCsv - type declarations", () => { "# type IntentEffects = IntentEffect[]", "id,effects", "string,IntentEffects", - "boost,[user;spike;1];[user;defend;4]", + "boost,[[user;spike;1];[user;defend;4]]", ].join("\n"); const result = parseCsv(csv, { @@ -260,7 +260,7 @@ describe("parseCsv - type declarations", () => { '# type Type = "apple" | "orange"', "id,items", "string,[Type; int][]", - "001,[apple;2];[orange;3]", + "001,[[apple;2];[orange;3]]", ].join("\n"); const result = parseCsv(csv, { emitTypes: false }); @@ -292,7 +292,7 @@ describe("parseCsv - type declarations", () => { "# type Entry = [@user]", "id,entry", "string,Entry", - "1,1", + "1,[1]", ].join("\n"); const result = parseCsv(csv, { diff --git a/src/index.test.ts b/src/index.test.ts index b76f813..e3d3bb0 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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", () => { diff --git a/src/parser.ts b/src/parser.ts index 5e3eb8f..d787574 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -250,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 }; } diff --git a/src/type-utils.ts b/src/type-utils.ts index 783497e..638e3c8 100644 --- a/src/type-utils.ts +++ b/src/type-utils.ts @@ -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})[]`; diff --git a/src/value-parser.ts b/src/value-parser.ts index 6f8b070..297ec5e 100644 --- a/src/value-parser.ts +++ b/src/value-parser.ts @@ -41,7 +41,7 @@ class ValueParser { return false; } - parseValue(schema: Schema, allowOmitBrackets: boolean = false): unknown { + parseValue(schema: Schema): unknown { this.skipWhitespace(); switch (schema.type) { @@ -60,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); @@ -250,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 @@ -266,16 +266,8 @@ class ValueParser { ); } - private parseTupleValue( - schema: TupleSchema, - allowOmitBrackets: boolean, - ): unknown[] { - let hasOpenBracket = false; - - if (this.peek() === "[") { - this.consume(); - hasOpenBracket = true; - } else if (!allowOmitBrackets) { + private parseTupleValue(schema: TupleSchema): unknown[] { + if (!this.consumeStr("[")) { throw new ParseError( "Expected [", this.pos, @@ -286,7 +278,7 @@ class ValueParser { this.skipWhitespace(); - if (this.peek() === "]" && hasOpenBracket) { + if (this.peek() === "]") { this.consume(); return []; } @@ -308,7 +300,7 @@ class ValueParser { } } - result.push(this.parseValue(elementSchema.schema, false)); + result.push(this.parseValue(elementSchema.schema)); this.skipWhitespace(); if (i < schema.elements.length - 1) { @@ -325,57 +317,24 @@ class ValueParser { this.skipWhitespace(); - if (hasOpenBracket) { - if (!this.consumeStr("]")) { - throw new ParseError( - "Expected ]", - this.pos, - this.schemaString, - this.input, - ); - } + 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) { + if (!this.consumeStr("[")) { throw new ParseError( "Expected [", this.pos, @@ -386,7 +345,7 @@ class ValueParser { this.skipWhitespace(); - if (this.peek() === "]" && hasOpenBracket) { + if (this.peek() === "]") { this.consume(); return []; } @@ -395,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(";")) { @@ -405,15 +364,13 @@ class ValueParser { this.skipWhitespace(); - if (hasOpenBracket) { - if (!this.consumeStr("]")) { - throw new ParseError( - "Expected ]", - this.pos, - this.schemaString, - this.input, - ); - } + if (!this.consumeStr("]")) { + throw new ParseError( + "Expected ]", + this.pos, + this.schemaString, + this.input, + ); } return result; @@ -431,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 []; } @@ -464,15 +424,15 @@ class ValueParser { } } - if (hasOpenBracket) { - if (!this.consumeStr("]")) { - throw new ParseError( - "Expected ]", - this.pos, - this.schemaString, - this.input, - ); - } + this.skipWhitespace(); + + if (!this.consumeStr("]")) { + throw new ParseError( + "Expected ]", + this.pos, + this.schemaString, + this.input, + ); } return ids; @@ -506,8 +466,7 @@ export function parseValue( ): unknown { const sStr = schemaString || schemaToTypeString(schema); const parser = new ValueParser(valueString.trim(), sStr); - const allowOmitBrackets = schema.type === "tuple" || schema.type === "array"; - const value = parser.parseValue(schema, allowOmitBrackets); + const value = parser.parseValue(schema); if (parser.getPosition() < parser.getInputLength()) { throw new ParseError(