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.
This commit is contained in:
2026-08-06 09:19:39 +08:00
parent 7db0742f50
commit c969c7f6fc
11 changed files with 89 additions and 164 deletions
+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
+1 -1
View File
@@ -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]]
1 # type IntentEffectTarget = 'user' | 'eachEnemy' | 'randomEnemy' | 'player'
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] 仙人掌怪-boost,仙人掌怪,true,[仙人掌怪-boost;仙人掌怪-defend],,[[user;spike;1];[user;defend;4]]
7
@@ -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, {
+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", () => {
+1 -14
View File
@@ -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 };
}
-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})[]`;
+44 -85
View File
@@ -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(