@cardenelabs/dragon 0.35.0 → 0.36.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 +41 -3
- package/dist/cli.cjs +597 -375
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +597 -375
- package/dist/cli.js.map +1 -1
- package/dist/export.cjs +104 -37
- package/dist/export.cjs.map +1 -1
- package/dist/export.d.cts +1 -1
- package/dist/export.d.ts +1 -1
- package/dist/export.js +104 -37
- package/dist/export.js.map +1 -1
- package/dist/index.cjs +833 -526
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -15
- package/dist/index.d.ts +21 -15
- package/dist/index.js +833 -526
- package/dist/index.js.map +1 -1
- package/dist/player.global.js +2 -2
- package/dist/{schema-BBWfniwH.d.cts → schema-9VLameDG.d.cts} +15 -3
- package/dist/{schema-BBWfniwH.d.ts → schema-9VLameDG.d.ts} +15 -3
- package/package.json +20 -8
- package/src/cli.ts +13 -14
- package/src/figure-text.ts +1 -1
- package/src/index.ts +4 -4
- package/src/look/values.ts +16 -4
- package/src/messages.ts +158 -80
- package/src/parser.ts +402 -245
- package/src/player.ts +80 -9
- package/src/printer.ts +90 -60
- package/src/render/parts.ts +28 -8
- package/src/render/svg.ts +128 -33
- package/src/schema.ts +1 -1
- package/src/tokenize.ts +11 -8
- package/src/view.ts +5 -2
- package/src/words.ts +39 -65
package/src/parser.ts
CHANGED
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 人が書く dragon記法を、図の原本の JSON へ変える (#3137)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* 行の種類は行の頭の英語の語で決まり、線の行だけは箱の名札から始まる。最初の `step` より前に
|
|
5
|
+
* 図の種類と題と区画と箱と帯を宣言し、`step` ごとに、その段で起きることを起きる順に並べる。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
7
|
+
* sequence <題> [view: fixed か follow] 書かなければ字の大きさで選ぶ
|
|
8
|
+
* group <名札> as <名前> [note: <添え書き>, color: <色>, icon: <印>]
|
|
9
|
+
* box <名札> as <名前> [note: <下の小さな字>, color: <色>, icon: <印>]
|
|
10
|
+
* end 同じ名札の group はもう一度開ける
|
|
11
|
+
* box <名札> as <名前> [...] as が無ければ名札を名前にする
|
|
12
|
+
* strip <題>: <欄>, <欄>
|
|
13
|
+
* step <段の題>
|
|
14
|
+
* say <段の本文> say の下に字下げした行は本文の続き
|
|
15
|
+
* code <段で打つ命令>
|
|
16
|
+
* <名札> -> <名札>: <線の名前> [carry: <運ぶ物>, tag: <値>, side: <側>]
|
|
17
|
+
* act <名札>: <処理の名前> 箱の中の処理
|
|
18
|
+
* note <名札>: <字> 箱の下の小さな字の書き換え
|
|
19
|
+
* set <欄>: <値> [tag: <札>] 中身の帯の値
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* 鍵と値はコロンで区切り、属性は `[ ]` の中に `,` で並べて行の終わりに置く。
|
|
22
|
+
* 空白を含む名前と文は行の終わりか `[` の前に置き、`[` を含む名前と `,` か `]` を含む属性の値だけを
|
|
23
|
+
* `"` で囲む。囲みの中では `\"` が `"`、`\\` が `\` になる。字下げは `say` の続きの行を見分ける時だけ意味を持つ。
|
|
23
24
|
*
|
|
24
|
-
* 行の頭の語と同じ綴りの名札 (
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* 誤りは最初の 1 つで止め、行と列を示す。
|
|
25
|
+
* 行の頭の語と同じ綴りの名札 (`end` や `step`) も書ける。名札の次に `->` が来る行は線と読む。
|
|
26
|
+
* 誤りは最初の 1 つで止め、行と列を示す。誤りの文の言葉は呼ぶ側が渡す。
|
|
28
27
|
*/
|
|
29
|
-
import { type Messages, messagesFor } from "./messages";
|
|
28
|
+
import { type Messages, messagesFor, type Shape } from "./messages";
|
|
30
29
|
import { type Box, type Carry, type Color, COLORS, type Diagram, type DiagramEvent, type LabelSide, type Region, type Step, type ViewMode } from "./schema";
|
|
31
|
-
import { columnOf, type SourceLine, tokenize } from "./tokenize";
|
|
30
|
+
import { columnOf, isBlankOrComment, type SourceLine, tokenize } from "./tokenize";
|
|
32
31
|
import { FORMAT_VERSION } from "./version";
|
|
33
|
-
import {
|
|
32
|
+
import { AS, ATTRS, CARRIES, HEADS, KINDS, type NotationLang, OLD_HEADS, SIDES, VIEWS } from "./words";
|
|
34
33
|
|
|
35
34
|
export interface LineError {
|
|
36
35
|
line: number;
|
|
@@ -41,7 +40,7 @@ export interface LineError {
|
|
|
41
40
|
export type Parsed = { ok: true; diagram: Diagram } | { ok: false; errors: LineError[] };
|
|
42
41
|
|
|
43
42
|
export interface ParseOptions {
|
|
44
|
-
/**
|
|
43
|
+
/** 誤りの文の言葉。既定は日本語 */
|
|
45
44
|
lang?: NotationLang;
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -58,108 +57,212 @@ class NotationError extends Error {
|
|
|
58
57
|
const fail = (source: SourceLine, index: number, message: string): NotationError =>
|
|
59
58
|
new NotationError(source.line, columnOf(source, index), message);
|
|
60
59
|
|
|
61
|
-
/**
|
|
60
|
+
/** 名札に使える字。原本の JSON の名札の形と同じ */
|
|
61
|
+
const ID = /^[^\s[\]「」=:]+$/u;
|
|
62
|
+
/** 線の行。名札、`->`、名札 */
|
|
63
|
+
const EDGE = /^([^\s:[\]]+?)\s*->\s*([^\s:[\]]+)/du;
|
|
64
|
+
|
|
65
|
+
const isOneOf = <T extends string>(list: readonly T[], word: string): word is T => (list as readonly string[]).includes(word);
|
|
66
|
+
|
|
67
|
+
/** `at` から空白を飛ばした位置 */
|
|
68
|
+
function skipSpaces(text: string, at: number): number {
|
|
69
|
+
let i = at;
|
|
70
|
+
while (i < text.length && /\s/u.test(text[i] ?? "")) i++;
|
|
71
|
+
return i;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `[<鍵>: <値>]` の 1 つ。`at` は鍵の位置 */
|
|
62
75
|
interface Attr {
|
|
63
|
-
raw: string;
|
|
64
|
-
key: string;
|
|
65
76
|
value: string;
|
|
66
77
|
at: number;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
|
-
/**
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
let
|
|
73
|
-
for (
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
/** `at` の `"` から閉じる `"` までを読む。`end` は閉じた `"` の次 */
|
|
81
|
+
function readQuoted(source: SourceLine, at: number, m: Messages): { value: string; end: number } {
|
|
82
|
+
const text = source.text;
|
|
83
|
+
let value = "";
|
|
84
|
+
for (let i = at + 1; i < text.length; i++) {
|
|
85
|
+
const c = text[i] ?? "";
|
|
86
|
+
const next = text[i + 1] ?? "";
|
|
87
|
+
if (c === "\\" && (next === '"' || next === "\\")) {
|
|
88
|
+
value += next;
|
|
89
|
+
i++;
|
|
90
|
+
} else if (c === '"') {
|
|
91
|
+
return { value, end: i + 1 };
|
|
92
|
+
} else {
|
|
93
|
+
value += c;
|
|
94
|
+
}
|
|
80
95
|
}
|
|
81
|
-
|
|
96
|
+
throw fail(source, at, m.quoteUnclosed);
|
|
82
97
|
}
|
|
83
98
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
function nameAndNote(text: string, lang: NotationLang): { name: string; note?: string } {
|
|
97
|
-
for (const [open, close] of QUOTES[lang]) {
|
|
98
|
-
const found = new RegExp(`^(.*?)\\s*${escape(open)}(.*)${escape(close)}$`).exec(text);
|
|
99
|
-
if (found) return { name: (found[1] ?? "").trim(), note: found[2] ?? "" };
|
|
99
|
+
/**
|
|
100
|
+
* `at` から名前か文を読む。`"` で始まれば囲みの中、そうでなければ `[` の前か行の終わりまで。
|
|
101
|
+
* `rest` は名前の後ろの位置で、行の終わりか属性の `[` を指す。
|
|
102
|
+
*/
|
|
103
|
+
function readName(source: SourceLine, at: number, m: Messages): { value: string; rest: number } {
|
|
104
|
+
const text = source.text;
|
|
105
|
+
const start = skipSpaces(text, at);
|
|
106
|
+
if (text[start] === '"') {
|
|
107
|
+
const quoted = readQuoted(source, start, m);
|
|
108
|
+
const rest = skipSpaces(text, quoted.end);
|
|
109
|
+
if (rest < text.length && text[rest] !== "[") throw fail(source, rest, m.afterQuote);
|
|
110
|
+
return { value: quoted.value, rest };
|
|
100
111
|
}
|
|
101
|
-
|
|
112
|
+
const open = text.indexOf("[", start);
|
|
113
|
+
const rest = open < 0 ? text.length : open;
|
|
114
|
+
return { value: text.slice(start, rest).trim(), rest };
|
|
102
115
|
}
|
|
103
116
|
|
|
104
|
-
/**
|
|
105
|
-
function
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
/** `at` の `[` から行の終わりの `]` までの属性を読む。`allowed` に無い鍵は誤り */
|
|
118
|
+
function readAttrs(source: SourceLine, at: number, allowed: readonly string[], m: Messages): Map<string, Attr> {
|
|
119
|
+
const text = source.text;
|
|
120
|
+
if (!text.endsWith("]")) throw fail(source, at, m.attrUnclosed);
|
|
121
|
+
const close = text.length - 1;
|
|
122
|
+
const attrs = new Map<string, Attr>();
|
|
123
|
+
for (let i = at + 1; ; ) {
|
|
124
|
+
const start = skipSpaces(text, i);
|
|
125
|
+
let colon = start;
|
|
126
|
+
while (colon < close && text[colon] !== ":" && text[colon] !== ",") colon++;
|
|
127
|
+
if (colon >= close || text[colon] !== ":") throw fail(source, start, m.attrNoColon(text.slice(start, colon).trim()));
|
|
128
|
+
const key = text.slice(start, colon).trim();
|
|
129
|
+
if (!allowed.includes(key)) throw fail(source, start, m.badAttr(key, allowed));
|
|
130
|
+
if (attrs.has(key)) throw fail(source, start, m.attrTwice(key));
|
|
131
|
+
let end = skipSpaces(text, colon + 1);
|
|
132
|
+
let value: string;
|
|
133
|
+
if (text[end] === '"') {
|
|
134
|
+
const quoted = readQuoted(source, end, m);
|
|
135
|
+
value = quoted.value;
|
|
136
|
+
end = skipSpaces(text, quoted.end);
|
|
137
|
+
if (end < close && text[end] !== ",") throw fail(source, end, m.afterQuote);
|
|
138
|
+
} else {
|
|
139
|
+
const comma = text.indexOf(",", end);
|
|
140
|
+
const stop = comma < 0 || comma > close ? close : comma;
|
|
141
|
+
value = text.slice(end, stop).trim();
|
|
142
|
+
end = stop;
|
|
143
|
+
}
|
|
144
|
+
if (value === "") throw fail(source, start, m.attrNoValue(key));
|
|
145
|
+
attrs.set(key, { value, at: start });
|
|
146
|
+
if (end >= close) return attrs;
|
|
147
|
+
i = end + 1;
|
|
110
148
|
}
|
|
111
|
-
return null;
|
|
112
149
|
}
|
|
113
150
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
151
|
+
/** `at` から `,` で区切った字の並びを行の終わりまで読む */
|
|
152
|
+
function readList(source: SourceLine, at: number, m: Messages): string[] {
|
|
153
|
+
const text = source.text;
|
|
154
|
+
const items: string[] = [];
|
|
155
|
+
for (let i = at; ; ) {
|
|
156
|
+
const start = skipSpaces(text, i);
|
|
157
|
+
let value: string;
|
|
158
|
+
let end: number;
|
|
159
|
+
if (text[start] === '"') {
|
|
160
|
+
const quoted = readQuoted(source, start, m);
|
|
161
|
+
value = quoted.value;
|
|
162
|
+
end = skipSpaces(text, quoted.end);
|
|
163
|
+
if (end < text.length && text[end] !== ",") throw fail(source, end, m.afterQuote);
|
|
164
|
+
} else {
|
|
165
|
+
const comma = text.indexOf(",", start);
|
|
166
|
+
end = comma < 0 ? text.length : comma;
|
|
167
|
+
value = text.slice(start, end).trim();
|
|
168
|
+
}
|
|
169
|
+
if (value === "") throw fail(source, start, m.emptyStripField);
|
|
170
|
+
items.push(value);
|
|
171
|
+
if (end >= text.length) return items;
|
|
172
|
+
i = end + 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `group` と `box` の行の、名札と `as` の後ろの名前と属性 */
|
|
177
|
+
interface Declared {
|
|
178
|
+
id: string;
|
|
179
|
+
idAt: number;
|
|
180
|
+
name?: string;
|
|
181
|
+
attrs: Map<string, Attr>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readDeclared(source: SourceLine, word: string, allowed: readonly string[], m: Messages): Declared {
|
|
185
|
+
const text = source.text;
|
|
186
|
+
const start = skipSpaces(text, word.length);
|
|
187
|
+
const id = /^[^\s[]+/u.exec(text.slice(start))?.[0];
|
|
188
|
+
if (id === undefined) throw fail(source, 0, m.needsId(word));
|
|
189
|
+
if (!ID.test(id)) throw fail(source, start, m.badId(id));
|
|
190
|
+
let rest = skipSpaces(text, start + id.length);
|
|
191
|
+
let name: string | undefined;
|
|
192
|
+
if (text.startsWith(AS, rest) && /^(\s|$)/u.test(text.slice(rest + AS.length, rest + AS.length + 1))) {
|
|
193
|
+
const named = readName(source, rest + AS.length, m);
|
|
194
|
+
if (named.value === "") throw fail(source, rest, m.emptyName(id));
|
|
195
|
+
name = named.value;
|
|
196
|
+
rest = named.rest;
|
|
197
|
+
}
|
|
198
|
+
if (rest < text.length && text[rest] !== "[") throw fail(source, rest, m.afterId);
|
|
199
|
+
const attrs = rest < text.length ? readAttrs(source, rest, allowed, m) : new Map<string, Attr>();
|
|
200
|
+
return { id, idAt: start, ...(name !== undefined ? { name } : {}), attrs };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 区画と箱の属性 (`note` と `color` と `icon`) */
|
|
204
|
+
function lookOf(source: SourceLine, attrs: Map<string, Attr>, m: Messages): { note?: string; color?: Color; icon?: string } {
|
|
205
|
+
const color = attrs.get("color");
|
|
206
|
+
if (color && !isOneOf(COLORS, color.value)) throw fail(source, color.at, m.badColor);
|
|
207
|
+
const note = attrs.get("note")?.value;
|
|
208
|
+
const icon = attrs.get("icon")?.value;
|
|
121
209
|
return {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// 空白を全部読んでから次の字を見る (`\s` を外すと、空白 2 つの `帯 -> 受け手` を帯の値と読む)
|
|
126
|
-
stripValue: new RegExp(`^${head("strip")}\\s+(?!\\s|->|:|=${opens})`),
|
|
210
|
+
...(note !== undefined ? { note } : {}),
|
|
211
|
+
...(color ? { color: color.value as Color } : {}),
|
|
212
|
+
...(icon !== undefined ? { icon } : {}),
|
|
127
213
|
};
|
|
128
214
|
}
|
|
129
215
|
|
|
216
|
+
/**
|
|
217
|
+
* 今の形 (#2997 まで) の行なら、その印と、新しい形で書く行の形。`inStep` は段の中の行か。
|
|
218
|
+
* 新しい形で読めない行の誤りの文で、書き直し方を示すために引く。
|
|
219
|
+
*/
|
|
220
|
+
function oldForm(text: string, inStep: boolean): { word: string; shape: Shape } | undefined {
|
|
221
|
+
const first = text.split(/\s/u)[0] ?? "";
|
|
222
|
+
if (first === "帯") return { word: first, shape: inStep ? "set" : "strip" };
|
|
223
|
+
if (Object.hasOwn(OLD_HEADS, first)) return { word: first, shape: OLD_HEADS[first as keyof typeof OLD_HEADS] };
|
|
224
|
+
if (text.startsWith("#")) return { word: "#", shape: "step" };
|
|
225
|
+
if (text.startsWith(">")) return { word: ">", shape: "say" };
|
|
226
|
+
if (text.startsWith("`")) return { word: "`", shape: "code" };
|
|
227
|
+
if (text.includes("「")) return { word: "「」", shape: inStep ? "note" : "box" };
|
|
228
|
+
if (/^[^\s=:]+\s*=(?!>)/u.test(text)) return { word: "=", shape: "box" };
|
|
229
|
+
if (inStep && /^[^\s:]+\s*:/u.test(text)) return { word: ":", shape: "act" };
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 読んでいる段。`body` は `say` を読むまで無い */
|
|
130
234
|
interface Building {
|
|
131
235
|
source: SourceLine;
|
|
132
236
|
title: string;
|
|
133
|
-
body
|
|
237
|
+
body?: string[];
|
|
134
238
|
code?: string;
|
|
135
239
|
events: DiagramEvent[];
|
|
136
240
|
}
|
|
137
241
|
|
|
138
|
-
function parseLines(lines: SourceLine[],
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const foreignWord = <G extends keyof typeof WORDS>(source: SourceLine, at: number, group: G, word: string): NotationError =>
|
|
154
|
-
fail(source, at, m.otherLanguageWord(word, wordOf(group, valueOf(group, word, other) as keyof (typeof WORDS)[G], lang)));
|
|
242
|
+
function parseLines(lines: SourceLine[], m: Messages): Diagram {
|
|
243
|
+
const headAt = lines.findIndex((source) => !isBlankOrComment(source));
|
|
244
|
+
const head = lines[headAt];
|
|
245
|
+
if (!head) throw new NotationError(1, 1, m.noHead);
|
|
246
|
+
const kind = head.text.split(/\s/u)[0] ?? "";
|
|
247
|
+
if (!isOneOf(KINDS, kind)) {
|
|
248
|
+
const old = oldForm(head.text, false);
|
|
249
|
+
throw fail(head, 0, old ? m.oldForm(old.word, old.shape) : m.badHead);
|
|
250
|
+
}
|
|
251
|
+
const titled = readName(head, kind.length, m);
|
|
252
|
+
if (titled.value === "") throw fail(head, 0, m.badHead);
|
|
253
|
+
const title = titled.value;
|
|
254
|
+
const shown = titled.rest < head.text.length ? readAttrs(head, titled.rest, ATTRS.diagram, m).get("view") : undefined;
|
|
255
|
+
if (shown && !isOneOf(VIEWS, shown.value)) throw fail(head, shown.at, m.badView);
|
|
256
|
+
const view = shown?.value as ViewMode | undefined;
|
|
155
257
|
|
|
156
258
|
const regions: Region[] = [];
|
|
157
259
|
const boxes: Box[] = [];
|
|
158
260
|
const ids = new Set<string>();
|
|
159
261
|
let strip: { title: string; fields: string[] } | undefined;
|
|
160
|
-
let view: ViewMode | undefined;
|
|
161
262
|
const steps: Step[] = [];
|
|
162
263
|
let building: Building | undefined;
|
|
264
|
+
/** 開いている `group` */
|
|
265
|
+
let group: { source: SourceLine; id: string } | undefined;
|
|
163
266
|
|
|
164
267
|
const declare = (source: SourceLine, id: string, at: number): void => {
|
|
165
268
|
if (ids.has(id)) throw fail(source, at, m.duplicate(id));
|
|
@@ -169,9 +272,18 @@ function parseLines(lines: SourceLine[], fallback: NotationLang): Diagram {
|
|
|
169
272
|
if (!boxes.some((box) => box.id === id)) throw fail(source, at, m.undeclaredBox(id));
|
|
170
273
|
return id;
|
|
171
274
|
};
|
|
275
|
+
/** 段の中に書く行の、書き先の段。最初の `step` より前なら誤り */
|
|
276
|
+
const inStep = (source: SourceLine): Building => {
|
|
277
|
+
if (!building) throw fail(source, 0, m.stepLineBeforeStep);
|
|
278
|
+
return building;
|
|
279
|
+
};
|
|
280
|
+
/** 宣言の行。最初の `step` より後なら誤り */
|
|
281
|
+
const beforeSteps = (source: SourceLine): void => {
|
|
282
|
+
if (building) throw fail(source, 0, m.declarationInStep);
|
|
283
|
+
};
|
|
172
284
|
const finish = (): void => {
|
|
173
285
|
if (!building) return;
|
|
174
|
-
if (building.body
|
|
286
|
+
if (building.body === undefined) throw fail(building.source, 0, m.noBody(building.title));
|
|
175
287
|
steps.push({
|
|
176
288
|
title: building.title,
|
|
177
289
|
body: building.body.join("\n"),
|
|
@@ -180,21 +292,74 @@ function parseLines(lines: SourceLine[], fallback: NotationLang): Diagram {
|
|
|
180
292
|
});
|
|
181
293
|
};
|
|
182
294
|
|
|
183
|
-
|
|
295
|
+
const rest = lines.slice(headAt + 1);
|
|
296
|
+
for (let i = 0; i < rest.length; i++) {
|
|
297
|
+
const source = rest[i];
|
|
298
|
+
if (!source || isBlankOrComment(source)) continue;
|
|
184
299
|
const text = source.text;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
building = { source, title: stepTitle, body: [], events: [] };
|
|
300
|
+
// 名札の次に `->` が来る行は、名札が行の頭の語と同じ綴りでも線と読む
|
|
301
|
+
const edge = EDGE.exec(text);
|
|
302
|
+
if (edge) {
|
|
303
|
+
inStep(source).events.push(readEdge(source, edge));
|
|
190
304
|
continue;
|
|
191
305
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
306
|
+
const word = text.split(/\s/u)[0] ?? "";
|
|
307
|
+
if (!isOneOf(HEADS, word)) {
|
|
308
|
+
const old = oldForm(text, building !== undefined);
|
|
309
|
+
throw fail(source, 0, old ? m.oldForm(old.word, old.shape) : m.unknownHead(word));
|
|
310
|
+
}
|
|
311
|
+
switch (word) {
|
|
312
|
+
case "group":
|
|
313
|
+
readGroup(source);
|
|
314
|
+
break;
|
|
315
|
+
case "end":
|
|
316
|
+
if (text !== word) throw fail(source, word.length, m.endTakesNothing);
|
|
317
|
+
if (!group) throw fail(source, 0, m.strayEnd);
|
|
318
|
+
group = undefined;
|
|
319
|
+
break;
|
|
320
|
+
case "box":
|
|
321
|
+
readBox(source);
|
|
322
|
+
break;
|
|
323
|
+
case "strip":
|
|
324
|
+
readStrip(source);
|
|
325
|
+
break;
|
|
326
|
+
case "step": {
|
|
327
|
+
if (group) throw fail(group.source, 0, m.unclosedGroup(group.id));
|
|
328
|
+
finish();
|
|
329
|
+
const stepTitle = text.slice(word.length).trim();
|
|
330
|
+
if (stepTitle === "") throw fail(source, 0, m.emptyStepTitle);
|
|
331
|
+
building = { source, title: stepTitle, events: [] };
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
case "say":
|
|
335
|
+
i = readSay(source, i);
|
|
336
|
+
break;
|
|
337
|
+
case "code": {
|
|
338
|
+
const step = inStep(source);
|
|
339
|
+
if (step.code !== undefined) throw fail(source, 0, m.codeTwice);
|
|
340
|
+
const command = text.slice(word.length).trim();
|
|
341
|
+
if (command === "") throw fail(source, 0, m.emptyCode);
|
|
342
|
+
step.code = command;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "act": {
|
|
346
|
+
const step = inStep(source);
|
|
347
|
+
const on = readOnBox(source, word, m.actShape);
|
|
348
|
+
step.events.push({ type: "action", box: on.box, label: on.text });
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case "note": {
|
|
352
|
+
const step = inStep(source);
|
|
353
|
+
const on = readOnBox(source, word, m.noteShape);
|
|
354
|
+
step.events.push({ type: "swap", box: on.box, text: on.text });
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "set":
|
|
358
|
+
inStep(source).events.push(readSet(source));
|
|
359
|
+
break;
|
|
195
360
|
}
|
|
196
|
-
building.events.push(...parseStepLine(source, building));
|
|
197
361
|
}
|
|
362
|
+
if (group) throw fail(group.source, 0, m.unclosedGroup(group.id));
|
|
198
363
|
finish();
|
|
199
364
|
|
|
200
365
|
if (boxes.length === 0) throw fail(head, 0, m.noBoxes);
|
|
@@ -212,173 +377,165 @@ function parseLines(lines: SourceLine[], fallback: NotationLang): Diagram {
|
|
|
212
377
|
...(kind === "sequence" ? { sequence: { lifelines: boxes.map((box) => box.id) } } : {}),
|
|
213
378
|
};
|
|
214
379
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
throw fail(source, at, m.badView);
|
|
226
|
-
}
|
|
227
|
-
view = chosen;
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
const stripDecl = own.strip.exec(text);
|
|
231
|
-
if (stripDecl) {
|
|
232
|
-
if (strip) throw fail(source, 0, m.stripTwice);
|
|
233
|
-
const fields = (stripDecl[2] ?? "").split(",").map((field) => field.trim());
|
|
234
|
-
if (fields.some((field) => field === "")) throw fail(source, 0, m.emptyStripField);
|
|
235
|
-
strip = { title: stripDecl[1] ?? "", fields };
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const regionDecl = own.region.exec(text);
|
|
239
|
-
const boxDecl = regionDecl ? undefined : /^([^\s=]+)\s*=(?!>)\s*/d.exec(text);
|
|
240
|
-
const decl = regionDecl ?? boxDecl;
|
|
241
|
-
if (!decl?.indices?.[1]) {
|
|
242
|
-
if (foreign.view.test(text) || foreign.strip.test(text) || foreign.region.test(text)) {
|
|
243
|
-
throw foreignWord(source, 0, "head", text.split(/\s+/)[0] ?? "");
|
|
244
|
-
}
|
|
245
|
-
if (looksLikeStepLine(text, lang)) throw fail(source, 0, m.stepLineBeforeStep);
|
|
246
|
-
throw fail(source, 0, m.unreadableDeclaration);
|
|
247
|
-
}
|
|
248
|
-
const id = decl[1] ?? "";
|
|
249
|
-
declare(source, id, decl.indices[1][0]);
|
|
250
|
-
const { end, attrs } = trailingAttrs(source, decl[0].length);
|
|
251
|
-
const { name, note } = nameAndNote(text.slice(decl[0].length, end), lang);
|
|
252
|
-
if (name === "") throw fail(source, decl[0].length, m.emptyName(id));
|
|
253
|
-
const look: { color?: Color; icon?: string; region?: string } = {};
|
|
254
|
-
for (const attr of attrs) {
|
|
255
|
-
const key = valueOf("attr", attr.key, lang);
|
|
256
|
-
if (key === "color") {
|
|
257
|
-
if (!COLORS.includes(attr.value as Color)) throw fail(source, attr.at, m.badColor);
|
|
258
|
-
look.color = attr.value as Color;
|
|
259
|
-
} else if (key === "icon" && attr.value !== "") {
|
|
260
|
-
look.icon = attr.value;
|
|
261
|
-
} else if (key === "region" && !regionDecl) {
|
|
262
|
-
if (!regions.some((region) => region.id === attr.value)) throw fail(source, attr.at, m.regionFirst(attr.value));
|
|
263
|
-
look.region = attr.value;
|
|
264
|
-
} else {
|
|
265
|
-
if (valueOf("attr", attr.key, other) !== undefined) throw foreignWord(source, attr.at, "attr", attr.key);
|
|
266
|
-
throw fail(source, attr.at, m.badAttr(attr.raw));
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
if (regionDecl) {
|
|
270
|
-
regions.push({
|
|
271
|
-
id,
|
|
272
|
-
name,
|
|
273
|
-
...(note !== undefined ? { note } : {}),
|
|
274
|
-
...(look.color ? { color: look.color } : {}),
|
|
275
|
-
...(look.icon ? { icon: look.icon } : {}),
|
|
276
|
-
});
|
|
277
|
-
return;
|
|
380
|
+
/** `group` の行。同じ名札をもう一度開いた時は、名前も属性も書かない */
|
|
381
|
+
function readGroup(source: SourceLine): void {
|
|
382
|
+
beforeSteps(source);
|
|
383
|
+
if (group) throw fail(source, 0, m.nestedGroup);
|
|
384
|
+
const declared = readDeclared(source, "group", ATTRS.group, m);
|
|
385
|
+
if (regions.some((region) => region.id === declared.id)) {
|
|
386
|
+
if (declared.name !== undefined || declared.attrs.size > 0) throw fail(source, 0, m.regionReopened(declared.id));
|
|
387
|
+
} else {
|
|
388
|
+
declare(source, declared.id, declared.idAt);
|
|
389
|
+
regions.push({ id: declared.id, name: declared.name ?? declared.id, ...lookOf(source, declared.attrs, m) });
|
|
278
390
|
}
|
|
391
|
+
group = { source, id: declared.id };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** `box` の行。開いている `group` の中なら、その区画に置く */
|
|
395
|
+
function readBox(source: SourceLine): void {
|
|
396
|
+
beforeSteps(source);
|
|
397
|
+
const declared = readDeclared(source, "box", ATTRS.box, m);
|
|
398
|
+
declare(source, declared.id, declared.idAt);
|
|
399
|
+
const { note, ...look } = lookOf(source, declared.attrs, m);
|
|
279
400
|
boxes.push({
|
|
280
|
-
id,
|
|
281
|
-
name,
|
|
401
|
+
id: declared.id,
|
|
402
|
+
name: declared.name ?? declared.id,
|
|
282
403
|
...(note !== undefined ? { sub: note } : {}),
|
|
283
|
-
...
|
|
284
|
-
...(
|
|
285
|
-
...(look.region ? { region: look.region } : {}),
|
|
404
|
+
...look,
|
|
405
|
+
...(group ? { region: group.id } : {}),
|
|
286
406
|
});
|
|
287
407
|
}
|
|
288
408
|
|
|
289
|
-
|
|
409
|
+
/** `strip <題>: <欄>, <欄>` の行 */
|
|
410
|
+
function readStrip(source: SourceLine): void {
|
|
411
|
+
beforeSteps(source);
|
|
412
|
+
if (strip) throw fail(source, 0, m.stripTwice);
|
|
290
413
|
const text = source.text;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
414
|
+
const start = skipSpaces(text, "strip".length);
|
|
415
|
+
let stripTitle: string;
|
|
416
|
+
let colon: number;
|
|
417
|
+
if (text[start] === '"') {
|
|
418
|
+
const quoted = readQuoted(source, start, m);
|
|
419
|
+
stripTitle = quoted.value;
|
|
420
|
+
colon = skipSpaces(text, quoted.end);
|
|
421
|
+
} else {
|
|
422
|
+
colon = text.indexOf(":", start);
|
|
423
|
+
stripTitle = colon < 0 ? "" : text.slice(start, colon).trim();
|
|
300
424
|
}
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
if (text.includes("->")) return [parseEdge(source)];
|
|
304
|
-
if (/^[^\s=]+\s*=(?!>)/.test(text)) throw fail(source, 0, m.declarationInStep);
|
|
305
|
-
const swap = swapLine(text, lang);
|
|
306
|
-
if (swap?.indices?.[1]) {
|
|
307
|
-
const box = boxAt(source, swap[1] ?? "", swap.indices[1][0]);
|
|
308
|
-
if ((swap[2] ?? "") === "") throw fail(source, swap.indices[1][1], m.emptySwap);
|
|
309
|
-
return [{ type: "swap", box, text: swap[2] ?? "" }];
|
|
310
|
-
}
|
|
311
|
-
const action = /^([^\s:]+)\s*:\s*(.+)$/d.exec(text);
|
|
312
|
-
if (action?.indices?.[1]) return [{ type: "action", box: boxAt(source, action[1] ?? "", action.indices[1][0]), label: action[2] ?? "" }];
|
|
313
|
-
throw fail(source, 0, m.unreadableStepLine);
|
|
425
|
+
if (stripTitle === "" || text[colon] !== ":") throw fail(source, 0, m.stripShape);
|
|
426
|
+
strip = { title: stripTitle, fields: readList(source, colon + 1, m) };
|
|
314
427
|
}
|
|
315
428
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (attr.value !== "") throw fail(source, attr.at, m.carryTakesNoValue(attr.key));
|
|
334
|
-
carry = { kind };
|
|
335
|
-
}
|
|
336
|
-
} else if (label && valueOf("side", attr.value, lang) !== undefined) {
|
|
337
|
-
labelSide = valueOf("side", attr.value, lang);
|
|
338
|
-
} else {
|
|
339
|
-
if (label && valueOf("side", attr.value, other) !== undefined) throw foreignWord(source, attr.at, "side", attr.value);
|
|
340
|
-
if (valueOf("carry", attr.key, other) !== undefined) throw foreignWord(source, attr.at, "carry", attr.key);
|
|
341
|
-
if (valueOf("attr", attr.key, other) !== undefined) throw foreignWord(source, attr.at, "attr", attr.key);
|
|
342
|
-
throw fail(source, attr.at, m.badAttr(attr.raw));
|
|
429
|
+
/**
|
|
430
|
+
* `say` の行と、その下に `say` より深く字下げして続けた行を段の本文にする。
|
|
431
|
+
* 続きの行の間の空行は本文の空の行になる。返すのは読み終えた最後の行の番号。
|
|
432
|
+
*/
|
|
433
|
+
function readSay(source: SourceLine, index: number): number {
|
|
434
|
+
const step = inStep(source);
|
|
435
|
+
if (step.body !== undefined) throw fail(source, 0, m.sayTwice);
|
|
436
|
+
const inline = source.text.slice("say".length).trim();
|
|
437
|
+
const body = inline === "" ? [] : [inline];
|
|
438
|
+
let blanks = 0;
|
|
439
|
+
let last = index;
|
|
440
|
+
for (let j = index + 1; j < rest.length; j++) {
|
|
441
|
+
const next = rest[j];
|
|
442
|
+
if (!next) break;
|
|
443
|
+
if (next.text === "") {
|
|
444
|
+
blanks++;
|
|
445
|
+
continue;
|
|
343
446
|
}
|
|
447
|
+
if (next.indent <= source.indent) break;
|
|
448
|
+
body.push(...Array.from({ length: blanks }, () => ""), next.text);
|
|
449
|
+
blanks = 0;
|
|
450
|
+
last = j;
|
|
451
|
+
}
|
|
452
|
+
if (body.length === 0) throw fail(source, 0, m.emptySay);
|
|
453
|
+
step.body = body;
|
|
454
|
+
return last;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** `act <名札>: <字>` と `note <名札>: <字>` の行の、箱と字 */
|
|
458
|
+
function readOnBox(source: SourceLine, word: "act" | "note", shape: string): { box: string; text: string } {
|
|
459
|
+
const found = new RegExp(`^${word}\\s+([^\\s:]+)\\s*:\\s*(.*)$`, "du").exec(source.text);
|
|
460
|
+
const idAt = found?.indices?.[1]?.[0];
|
|
461
|
+
if (!found || idAt === undefined || (found[2] ?? "") === "") throw fail(source, 0, shape);
|
|
462
|
+
return { box: boxAt(source, found[1] ?? "", idAt), text: found[2] ?? "" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** `<名札> -> <名札>: <線の名前> [<属性>]` の行 */
|
|
466
|
+
function readEdge(source: SourceLine, edge: RegExpExecArray): DiagramEvent {
|
|
467
|
+
const text = source.text;
|
|
468
|
+
const from = boxAt(source, edge[1] ?? "", edge.indices?.[1]?.[0] ?? 0);
|
|
469
|
+
const to = boxAt(source, edge[2] ?? "", edge.indices?.[2]?.[0] ?? 0);
|
|
470
|
+
let at = skipSpaces(text, edge[0].length);
|
|
471
|
+
let label: string | undefined;
|
|
472
|
+
if (text[at] === ":") {
|
|
473
|
+
const named = readName(source, at + 1, m);
|
|
474
|
+
if (named.value === "") throw fail(source, at, m.emptyLabel);
|
|
475
|
+
label = named.value;
|
|
476
|
+
at = named.rest;
|
|
344
477
|
}
|
|
478
|
+
if (at < text.length && text[at] !== "[") throw fail(source, at, m.edgeShape);
|
|
479
|
+
const attrs = at < text.length ? readAttrs(source, at, ATTRS.edge, m) : new Map<string, Attr>();
|
|
480
|
+
const tag = attrs.get("tag");
|
|
481
|
+
const carried = attrs.get("carry");
|
|
482
|
+
if (tag && carried) throw fail(source, Math.max(tag.at, carried.at), m.carryTwice);
|
|
483
|
+
if (carried && !isOneOf(CARRIES, carried.value)) throw fail(source, carried.at, m.badCarry);
|
|
484
|
+
const side = attrs.get("side");
|
|
485
|
+
if (side && !isOneOf(SIDES, side.value)) throw fail(source, side.at, m.badSide);
|
|
486
|
+
let carry: Carry = { kind: "envelope" };
|
|
487
|
+
if (tag) carry = { kind: "tag", text: tag.value };
|
|
488
|
+
else if (carried) carry = carried.value === "coin" ? { kind: "coin" } : { kind: "none" };
|
|
345
489
|
return {
|
|
346
490
|
type: "edge",
|
|
347
491
|
from,
|
|
348
492
|
to,
|
|
349
|
-
...(
|
|
350
|
-
carry
|
|
351
|
-
...(
|
|
493
|
+
...(label !== undefined ? { label } : {}),
|
|
494
|
+
carry,
|
|
495
|
+
...(side ? { labelSide: side.value as LabelSide } : {}),
|
|
352
496
|
};
|
|
353
497
|
}
|
|
354
498
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
499
|
+
/** `set <欄>: <値> [tag: <札>]` か `set <欄> [tag: <札>]` の行 */
|
|
500
|
+
function readSet(source: SourceLine): DiagramEvent {
|
|
501
|
+
const declared = strip;
|
|
502
|
+
if (!declared) throw fail(source, 0, m.stripUndeclared);
|
|
503
|
+
const text = source.text;
|
|
504
|
+
const start = skipSpaces(text, "set".length);
|
|
505
|
+
let field: string;
|
|
506
|
+
let at: number;
|
|
507
|
+
if (text[start] === '"') {
|
|
508
|
+
const quoted = readQuoted(source, start, m);
|
|
509
|
+
field = quoted.value;
|
|
510
|
+
at = skipSpaces(text, quoted.end);
|
|
511
|
+
} else {
|
|
512
|
+
at = start;
|
|
513
|
+
while (at < text.length && text[at] !== ":" && text[at] !== "[") at++;
|
|
514
|
+
field = text.slice(start, at).trim();
|
|
515
|
+
}
|
|
516
|
+
if (!declared.fields.includes(field)) throw fail(source, start, m.noSuchField(field));
|
|
517
|
+
let value: string | undefined;
|
|
518
|
+
if (text[at] === ":") {
|
|
519
|
+
const named = readName(source, at + 1, m);
|
|
520
|
+
if (named.value === "") throw fail(source, at, m.stripNeedsValue);
|
|
521
|
+
value = named.value;
|
|
522
|
+
at = named.rest;
|
|
523
|
+
}
|
|
524
|
+
if (at < text.length && text[at] !== "[") throw fail(source, at, m.stripNeedsValue);
|
|
525
|
+
const tag = at < text.length ? readAttrs(source, at, ATTRS.set, m).get("tag")?.value : undefined;
|
|
526
|
+
if (value === undefined && tag === undefined) throw fail(source, 0, m.stripNeedsValue);
|
|
366
527
|
return {
|
|
367
528
|
type: "strip",
|
|
368
529
|
field,
|
|
369
|
-
...(
|
|
530
|
+
...(value !== undefined ? { value } : {}),
|
|
370
531
|
...(tag !== undefined ? { tag } : {}),
|
|
371
532
|
};
|
|
372
533
|
}
|
|
373
534
|
}
|
|
374
535
|
|
|
375
|
-
function looksLikeStepLine(text: string, lang: NotationLang): boolean {
|
|
376
|
-
return text.startsWith(">") || text.startsWith("`") || text.includes("->") || swapLine(text, lang) !== null;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
536
|
export function parseNotation(notation: string, options: ParseOptions = {}): Parsed {
|
|
380
537
|
try {
|
|
381
|
-
return { ok: true, diagram: parseLines(tokenize(notation), options.lang ?? "ja") };
|
|
538
|
+
return { ok: true, diagram: parseLines(tokenize(notation), messagesFor(options.lang ?? "ja")) };
|
|
382
539
|
} catch (error) {
|
|
383
540
|
if (!(error instanceof NotationError)) throw error;
|
|
384
541
|
return { ok: false, errors: [{ line: error.line, column: error.column, message: error.message }] };
|