@cardenelabs/dragon 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/dist/index.cjs +6610 -3358
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +578 -9
- package/dist/index.d.ts +578 -9
- package/dist/index.js +6606 -3360
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile.ts +2957 -256
- package/src/index.ts +29 -15
- package/src/input-size.ts +8 -1
- package/src/json-parser.ts +1384 -105
- package/src/notation-lint.ts +27 -4
- package/src/schemas/diagram.json +416 -41
- package/src/tokenize.ts +289 -0
- package/src/types.ts +144 -0
- package/src/v05/parser.ts +995 -163
- package/src/value-syntax.ts +313 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 値 (`values`) と状態 (`states`) の名前と式が、記法として書ける範囲に収まっているかの判定。
|
|
3
|
+
*
|
|
4
|
+
* **入口が 2 つあるので 1 か所に置く** (#1181)。 記法 (`v05/parser.ts`) と JSON
|
|
5
|
+
* (`json-parser.ts`) が同じ判定を使う。 別々に持つと片方だけが受け付ける形ができ、
|
|
6
|
+
* 「YAML では弾かれるのに JSON では通る」 (またはその逆) が生まれる。
|
|
7
|
+
*
|
|
8
|
+
* 位置の付け方だけが入口で違う (記法は行番号、JSON は field の path)。 そのため本 file は
|
|
9
|
+
* 位置を持たない指摘だけを返し、呼出側が自分の形に包む。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 値と状態の名前。 描画側が `{名前}` を置き換える時に見る範囲と揃える (英数字と `_` のみ) */
|
|
13
|
+
const VALUE_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
14
|
+
|
|
15
|
+
/** 式に書ける関数。 spec が「関数呼び出しは入れない」 としつつ例外にしている 2 つだけ */
|
|
16
|
+
const VALUE_FNS: ReadonlySet<string> = new Set(["min", "max"]);
|
|
17
|
+
|
|
18
|
+
/** 位置を持たない指摘。 呼出側が行番号 (記法) か path (JSON) を付けて報告する */
|
|
19
|
+
export type ValueSyntaxIssue = {
|
|
20
|
+
message: string;
|
|
21
|
+
hint?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** 値 / 状態の名前として使えるか */
|
|
25
|
+
export function isValueName(name: string): boolean {
|
|
26
|
+
return VALUE_NAME_RE.test(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 名前が使えない時の指摘 */
|
|
30
|
+
export function valueNameIssue(name: string): ValueSyntaxIssue {
|
|
31
|
+
return {
|
|
32
|
+
message: `invalid value name: "${name}"`,
|
|
33
|
+
hint: "英字か _ で始め、 英数字と _ だけを使う (states と同じ規則)",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** きっかけ形の読み取り結果。 位置は呼出側が付ける (本 file の方針) */
|
|
38
|
+
export type ParsedValueTrigger = {
|
|
39
|
+
trigger:
|
|
40
|
+
| { kind: "step"; step: string }
|
|
41
|
+
| { kind: "reaches"; source: string; op: TriggerOp; threshold: number };
|
|
42
|
+
to: number;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type TriggerOp = ">=" | ">" | "<=" | "<" | "==" | "!=";
|
|
47
|
+
|
|
48
|
+
/** 比較の記号。 **2 文字を先に見る** = `>=` を `>` と読むと境目が 1 つずれる */
|
|
49
|
+
const TRIGGER_OPS: readonly TriggerOp[] = [">=", "<=", "==", "!=", ">", "<"];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 動く長さを ms に直す。 段の頭 (`"取込み" 2s`) と同じ書き方を受ける。
|
|
53
|
+
*
|
|
54
|
+
* 単位を書かない形は秒として読む (段の頭と揃える)。 0 以下は動かないので受けない。
|
|
55
|
+
*/
|
|
56
|
+
export function parseDurationMs(text: string): number | null {
|
|
57
|
+
const m = text.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/);
|
|
58
|
+
if (!m) return null;
|
|
59
|
+
const n = parseFloat(m[1] ?? "0");
|
|
60
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
61
|
+
const unit = m[2] ?? "s";
|
|
62
|
+
const durationMs = unit === "ms" ? Math.round(n) : Math.round(n * 1000);
|
|
63
|
+
// 描画側の時計は ms の整数。 正の小数でも丸めた結果が 0ms なら、 後段で傾斜の式が
|
|
64
|
+
// 0 除算になるため、この入口で「動く長さではない」として弾く。
|
|
65
|
+
return durationMs > 0 ? durationMs : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 引用の外にある `,` だけで切る。
|
|
70
|
+
*
|
|
71
|
+
* 素朴な `split(",")` を使えない。 段の名前に `,` を書ける (`step "取込み, 検証"`) ため、
|
|
72
|
+
* 引用の中で切ると名前が 2 つに割れる。
|
|
73
|
+
*/
|
|
74
|
+
function splitOutsideQuotes(body: string): string[] {
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
let cur = "";
|
|
77
|
+
let quote: string | null = null;
|
|
78
|
+
for (const ch of body) {
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (ch === quote) quote = null;
|
|
81
|
+
cur += ch;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === '"' || ch === "'") {
|
|
85
|
+
quote = ch;
|
|
86
|
+
cur += ch;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (ch === ",") {
|
|
90
|
+
out.push(cur);
|
|
91
|
+
cur = "";
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
cur += ch;
|
|
95
|
+
}
|
|
96
|
+
out.push(cur);
|
|
97
|
+
return out.map((s) => s.trim()).filter((s) => s !== "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 引用を外す。 引用の中の `,` を守るために引用を残したまま切っているので、 ここで落とす */
|
|
101
|
+
function unquote(s: string): string {
|
|
102
|
+
const t = s.trim();
|
|
103
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) {
|
|
104
|
+
return t.slice(1, -1);
|
|
105
|
+
}
|
|
106
|
+
return t;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* きっかけの本体を読む (`step "取込み"` / `vDone >= 100`)。
|
|
111
|
+
*
|
|
112
|
+
* **`step` を先に見る**。 後に回すと `step "x"` が比較の記号を含まない形として
|
|
113
|
+
* 「相手の値の名前」 に落ち、 名前として不正という別の理由で弾かれる = 直し方が伝わらない。
|
|
114
|
+
*/
|
|
115
|
+
function parseTriggerExpression(raw: string, name: string): ParsedValueTrigger["trigger"] | ValueSyntaxIssue {
|
|
116
|
+
const text = raw.trim();
|
|
117
|
+
const stepMatch = text.match(/^step\s+(.+)$/);
|
|
118
|
+
if (stepMatch) {
|
|
119
|
+
const step = unquote(stepMatch[1] ?? "");
|
|
120
|
+
if (step === "") {
|
|
121
|
+
return { message: `trigger の段名が空です ("${name}")`, hint: '`trigger: step "取込み"` の形で段の名前を書く' };
|
|
122
|
+
}
|
|
123
|
+
return { kind: "step", step };
|
|
124
|
+
}
|
|
125
|
+
for (const op of TRIGGER_OPS) {
|
|
126
|
+
const at = text.indexOf(op);
|
|
127
|
+
if (at < 0) continue;
|
|
128
|
+
const source = text.slice(0, at).trim();
|
|
129
|
+
const rhs = text.slice(at + op.length).trim();
|
|
130
|
+
if (!isValueName(source)) {
|
|
131
|
+
return {
|
|
132
|
+
message: `trigger が見張る値の名前が使えません: "${source}" ("${name}")`,
|
|
133
|
+
hint: "英字か _ で始め、 英数字と _ だけを使う",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const threshold = Number(rhs);
|
|
137
|
+
if (rhs === "" || !Number.isFinite(threshold)) {
|
|
138
|
+
return {
|
|
139
|
+
message: `trigger の境目が数ではありません: "${rhs}" ("${name}")`,
|
|
140
|
+
hint: "`trigger: vDone >= 100` のように数で書く",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { kind: "reaches", source, op, threshold };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
message: `trigger の形が読めません: "${text}" ("${name}")`,
|
|
147
|
+
hint: '`step "取込み"` か `vDone >= 100` の形で書く',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 中括弧で囲まれた本体が「きっかけ形」 かを見分ける。
|
|
153
|
+
*
|
|
154
|
+
* 式も中括弧で始まり中括弧で終わることがある (`{a} + {b}` / `{a}`)。 見分けるのは
|
|
155
|
+
* **引用の外に `:` があるか**で、 式の中括弧は値の名前しか包まないため `:` を持たない。
|
|
156
|
+
*
|
|
157
|
+
* 名前で見分けない (`trigger` を含むか等)。 `{trigger} + 1` のように `trigger` という名前の値を
|
|
158
|
+
* 読む式が書けるため、 名前で見ると式をきっかけ形と読み違える。
|
|
159
|
+
*/
|
|
160
|
+
export function isTriggerBody(raw: string): boolean {
|
|
161
|
+
const t = raw.trim();
|
|
162
|
+
if (!t.startsWith("{") || !t.endsWith("}") || t.length < 2) return false;
|
|
163
|
+
const inner = t.slice(1, -1);
|
|
164
|
+
let quote: string | null = null;
|
|
165
|
+
for (const ch of inner) {
|
|
166
|
+
if (quote) {
|
|
167
|
+
if (ch === quote) quote = null;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (ch === '"' || ch === "'") {
|
|
171
|
+
quote = ch;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === ":") return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* きっかけ形の中身を読む (`{ trigger: step "取込み", to: 100, dur: 2s }`)。
|
|
181
|
+
*
|
|
182
|
+
* 3 つとも必須にする。 既定値を置くと「書かなかった」 と「その値を書いた」 が同じになり、
|
|
183
|
+
* 動かない時に何を直せばよいか分からなくなる。
|
|
184
|
+
*/
|
|
185
|
+
export function parseValueTriggerBody(
|
|
186
|
+
body: string,
|
|
187
|
+
name: string,
|
|
188
|
+
): { spec: ParsedValueTrigger; issues: [] } | { spec: null; issues: ValueSyntaxIssue[] } {
|
|
189
|
+
const issues: ValueSyntaxIssue[] = [];
|
|
190
|
+
const seen = new Map<string, string>();
|
|
191
|
+
for (const part of splitOutsideQuotes(body)) {
|
|
192
|
+
const at = part.indexOf(":");
|
|
193
|
+
if (at < 0) {
|
|
194
|
+
issues.push({ message: `"${part}" は key: value の形ではありません ("${name}")`, hint: "`trigger: ... , to: 100, dur: 2s` の形で書く" });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const key = part.slice(0, at).trim().toLowerCase();
|
|
198
|
+
const value = part.slice(at + 1).trim();
|
|
199
|
+
if (seen.has(key)) {
|
|
200
|
+
issues.push({ message: `"${key}" を 2 度書いています ("${name}")`, hint: "同じ項目は 1 度だけ書く" });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
seen.set(key, value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const known = new Set(["trigger", "to", "dur"]);
|
|
207
|
+
for (const key of seen.keys()) {
|
|
208
|
+
if (known.has(key)) continue;
|
|
209
|
+
issues.push({
|
|
210
|
+
message: `"${key}" は values に書けません ("${name}")`,
|
|
211
|
+
hint: `書けるのは ${[...known].join(" / ")} だけ`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const triggerRaw = seen.get("trigger");
|
|
216
|
+
let trigger: ParsedValueTrigger["trigger"] | null = null;
|
|
217
|
+
if (triggerRaw === undefined) {
|
|
218
|
+
issues.push({ message: `trigger がありません ("${name}")`, hint: '`trigger: step "取込み"` か `trigger: vDone >= 100` を書く' });
|
|
219
|
+
} else {
|
|
220
|
+
const parsed = parseTriggerExpression(triggerRaw, name);
|
|
221
|
+
if ("message" in parsed) issues.push(parsed);
|
|
222
|
+
else trigger = parsed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const toRaw = seen.get("to");
|
|
226
|
+
let to = 0;
|
|
227
|
+
if (toRaw === undefined) {
|
|
228
|
+
issues.push({ message: `to がありません ("${name}")`, hint: "`to: 100` のように動いた先の値を書く" });
|
|
229
|
+
} else {
|
|
230
|
+
to = Number(unquote(toRaw));
|
|
231
|
+
if (!Number.isFinite(to)) {
|
|
232
|
+
issues.push({ message: `to が数ではありません: "${toRaw}" ("${name}")`, hint: "`to: 100` のように数で書く" });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const durRaw = seen.get("dur");
|
|
237
|
+
let durationMs = 0;
|
|
238
|
+
if (durRaw === undefined) {
|
|
239
|
+
issues.push({ message: `dur がありません ("${name}")`, hint: "`dur: 2s` のように動く長さを書く" });
|
|
240
|
+
} else {
|
|
241
|
+
const parsedDur = parseDurationMs(unquote(durRaw));
|
|
242
|
+
if (parsedDur === null) {
|
|
243
|
+
issues.push({ message: `dur が読めません: "${durRaw}" ("${name}")`, hint: "`2s` / `1.5s` / `500ms` の形で、 0 より大きい長さを書く" });
|
|
244
|
+
} else {
|
|
245
|
+
durationMs = parsedDur;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (issues.length > 0 || trigger === null) return { spec: null, issues };
|
|
250
|
+
return { spec: { trigger, to, durationMs }, issues: [] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 式が記法として書ける範囲に収まっているかを見る。
|
|
255
|
+
*
|
|
256
|
+
* 式が文法として正しいかは見ない。 そこは描画側が評価する時に判定して、その値だけを止める
|
|
257
|
+
* (spec § 4.2 = 1 箇所の壊れで図全体を止めない)。 ここで見るのは **記法として書ける範囲に
|
|
258
|
+
* 収まっているか**で、描画側が受け付けるが記法としては認めない書き方 (余り / 条件分岐 /
|
|
259
|
+
* `min` `max` 以外の関数) を弾く。
|
|
260
|
+
*
|
|
261
|
+
* `{名前}` の中身と外側を分けて見る。 分けないと、名前に紛れた記号を式の記号と読み違える。
|
|
262
|
+
*/
|
|
263
|
+
export function checkValueExpression(expression: string, name: string): ValueSyntaxIssue[] {
|
|
264
|
+
const issues: ValueSyntaxIssue[] = [];
|
|
265
|
+
|
|
266
|
+
// `{名前}` の中身は名前の規則で見る
|
|
267
|
+
const refRe = /\{([^}]*)\}/g;
|
|
268
|
+
let m: RegExpExecArray | null;
|
|
269
|
+
while ((m = refRe.exec(expression)) !== null) {
|
|
270
|
+
const ref = (m[1] ?? "").trim();
|
|
271
|
+
if (!VALUE_NAME_RE.test(ref)) {
|
|
272
|
+
issues.push({
|
|
273
|
+
message: `invalid reference "{${ref}}" in "${name}"`,
|
|
274
|
+
hint: "英字か _ で始め、 英数字と _ だけを使う",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (expression.includes("{") && !expression.includes("}")) {
|
|
279
|
+
issues.push({ message: `unclosed "{" in "${name}"`, hint: "`{名前}` の形で閉じる" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 名前を外した残りが式の骨格。 ここに記法外の記号や関数が無いかを見る
|
|
283
|
+
const outside = expression.replace(/\{[^}]*\}/g, " ");
|
|
284
|
+
if (outside.includes("%")) {
|
|
285
|
+
issues.push({ message: `"%" は式に書けない ("${name}")`, hint: "四則 (+ - * /) だけを使う" });
|
|
286
|
+
}
|
|
287
|
+
if (outside.includes("?")) {
|
|
288
|
+
issues.push({
|
|
289
|
+
message: `条件分岐 (?:) は式に書けない ("${name}")`,
|
|
290
|
+
hint: "比較の結果は真 = 1 / 偽 = 0 の数になるので、 掛け算で切り替える",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
for (const fn of outside.matchAll(/[a-zA-Z_][a-zA-Z0-9_.]*/g)) {
|
|
294
|
+
const word = fn[0];
|
|
295
|
+
if (VALUE_FNS.has(word)) continue;
|
|
296
|
+
issues.push({
|
|
297
|
+
message: `"${word}" は式に書けない ("${name}")`,
|
|
298
|
+
hint:
|
|
299
|
+
word.startsWith("Math.")
|
|
300
|
+
? "min / max は Math. を付けずに書く"
|
|
301
|
+
: `使えるのは ${[...VALUE_FNS].join(" / ")} だけ。 値は {名前} で読む`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const stray = outside.replace(/[a-zA-Z_][a-zA-Z0-9_.]*/g, " ").match(/[^0-9.,+\-*/()<>=!\s]/g);
|
|
305
|
+
if (stray) {
|
|
306
|
+
issues.push({
|
|
307
|
+
message: `"${[...new Set(stray)].join("")}" は式に書けない ("${name}")`,
|
|
308
|
+
hint: "四則 (+ - * /) / 括弧 / 比較 (> >= < <= == !=) / min / max だけを使う",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return issues;
|
|
313
|
+
}
|