@cardenelabs/dragon 0.35.1 → 0.36.1

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/src/parser.ts CHANGED
@@ -1,36 +1,35 @@
1
1
  /**
2
- * 人が書く記法を、図の原本の JSON へ変える。
2
+ * 人が書く dragon記法を、図の原本の JSON へ変える (#3137)。
3
3
  *
4
- * 記法は 1 行が 1 本の線になる行ごとの文で、最初の段の見出しより前に図の種類と題と箱を宣言し、
5
- * 段ごとに見出し (`#`) と本文 (`>`) の下へ、その段で起きることを起きる順に 1 行ずつ並べる。
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
- * 語は英語でも書ける (#2997)。`sequence` / `architecture` で始めた file は、語を `words.ts` の表の
21
- * 英語の語で書き、`「…」` の代わりに `"…"` でも囲める。1 つの file の言葉は最初の行の図の種類の語で
22
- * 決まり、違う言葉の語を書いた行は誤りにする。誤りの文も file の言葉で出す。
21
+ * 鍵と値はコロンで区切り、属性は `[ ]` の中に `,` で並べて行の終わりに置く。
22
+ * 空白を含む名前と文は行の終わりか `[` の前に置き、`[` を含む名前と `,` か `]` を含む属性の値だけを
23
+ * `"` で囲む。囲みの中では `\"` が `"`、`\\` が `\` になる。字下げは `say` の続きの行を見分ける時だけ意味を持つ。
23
24
  *
24
- * 行の頭の語と同じ綴りの名札 (`帯` や `strip`) も書ける。行の頭の語と名札は行の形で見分け、
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 { languageOf, type NotationLang, otherLang, valueOf, wordOf, type WORDS } from "./words";
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
- /** 最初の行が読めず file の言葉が決まらない時に、誤りの文に使う言葉。既定は日本語 */
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
- /** `[鍵 値]` の指定。`raw` は括弧の中身そのまま、`at` は `[` の位置。 */
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
- /** 行の `start` から後ろの終わりに並ぶ `[...]` を外す。`end` は残った字の終わり。 */
70
- function trailingAttrs(source: SourceLine, start: number): { end: number; attrs: Attr[] } {
71
- const attrs: Attr[] = [];
72
- let end = source.text.length;
73
- for (;;) {
74
- const found = /\s*\[([^[\]]*)\]$/.exec(source.text.slice(start, end));
75
- if (!found) break;
76
- const raw = (found[1] ?? "").trim();
77
- const [key = "", ...words] = raw.split(/\s+/);
78
- attrs.unshift({ raw, key, value: words.join(" "), at: start + found.index + found[0].indexOf("[") });
79
- end = start + found.index;
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
- return { end, attrs };
96
+ throw fail(source, at, m.quoteUnclosed);
82
97
  }
83
98
 
84
- const escape = (word: string): string => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
-
86
- /** 字を囲む形。日本語の語の file は「」だけ、英語の語の file は「」と "" を読む */
87
- const QUOTES: Record<NotationLang, readonly (readonly [string, string])[]> = {
88
- ja: [["「", "」"]],
89
- en: [
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
- return { name: text.trim() };
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 swapLine(text: string, lang: NotationLang): RegExpExecArray | null {
106
- const opens = QUOTES[lang].map(([o]) => escape(o)).join("");
107
- for (const [open, close] of QUOTES[lang]) {
108
- const found = new RegExp(`^([^\\s${opens}]+)\\s*${escape(open)}(.*)${escape(close)}$`, "d").exec(text);
109
- if (found) return found;
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
- * 1 つの言葉の、行の頭の語で始まる行の形。
116
- * `reading` は読んでいる file の言葉で、帯の値の行から外す箱の行 (名札の次に囲みが来る行) の形を決める。
117
- */
118
- function headShapes(lang: NotationLang, reading: NotationLang) {
119
- const head = (key: keyof typeof WORDS.head): string => escape(wordOf("head", key, lang));
120
- const opens = QUOTES[reading].map(([open]) => `|${escape(open)}`).join("");
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
- view: new RegExp(`^${head("view")}\\s+(\\S+)$`),
123
- strip: new RegExp(`^${head("strip")}\\s+(.+?)\\s*=\\s*(.+)$`),
124
- region: new RegExp(`^${head("region")}\\s+([^\\s=]+)\\s*=\\s*`, "d"),
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: string[];
237
+ body?: string[];
134
238
  code?: string;
135
239
  events: DiagramEvent[];
136
240
  }
137
241
 
138
- function parseLines(lines: SourceLine[], fallback: NotationLang): Diagram {
139
- const [head, ...rest] = lines;
140
- if (!head) throw new NotationError(1, 1, messagesFor(fallback).noHead);
141
- const found = languageOf(head.text);
142
- const m: Messages = messagesFor(found ?? fallback);
143
- const header = /^(\S+)\s+(.+)$/.exec(head.text);
144
- const kind = found && valueOf("kind", header?.[1] ?? "", found);
145
- if (!found || !header || !kind) throw fail(head, 0, m.badHead);
146
- const lang: NotationLang = found;
147
- const title = header[2] ?? "";
148
-
149
- const other = otherLang(lang);
150
- const own = headShapes(lang, lang);
151
- const foreign = headShapes(other, lang);
152
- /** 違う言葉の語を書いた行の誤り。`group` の役割の、この file の言葉の語を示す */
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.length === 0) throw fail(building.source, 0, m.noBody(building.title));
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
- for (const source of rest) {
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
- if (text.startsWith("#")) {
186
- finish();
187
- const stepTitle = text.slice(1).trim();
188
- if (stepTitle === "") throw fail(source, 0, m.emptyStepTitle);
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
- if (!building) {
193
- parseDeclaration(source);
194
- continue;
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
- function parseDeclaration(source: SourceLine): void {
216
- const text = source.text;
217
- const viewDecl = own.view.exec(text);
218
- if (viewDecl) {
219
- if (view) throw fail(source, 0, m.viewTwice);
220
- const word = viewDecl[1] ?? "";
221
- const at = text.indexOf(word);
222
- const chosen = valueOf("view", word, lang);
223
- if (!chosen) {
224
- if (valueOf("view", word, other) !== undefined) throw foreignWord(source, at, "view", word);
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
- ...(look.color ? { color: look.color } : {}),
284
- ...(look.icon ? { icon: look.icon } : {}),
285
- ...(look.region ? { region: look.region } : {}),
404
+ ...look,
405
+ ...(group ? { region: group.id } : {}),
286
406
  });
287
407
  }
288
408
 
289
- function parseStepLine(source: SourceLine, step: Building): DiagramEvent[] {
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
- if (text.startsWith(">")) {
292
- step.body.push(text.slice(1).trim());
293
- return [];
294
- }
295
- const code = /^`(.*)`$/.exec(text);
296
- if (code) {
297
- if (step.code !== undefined) throw fail(source, 0, m.codeTwice);
298
- step.code = code[1] ?? "";
299
- return [];
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 (own.stripValue.test(text)) return [parseStripValue(source)];
302
- if (foreign.stripValue.test(text)) throw foreignWord(source, 0, "head", text.split(/\s+/)[0] ?? "");
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
- function parseEdge(source: SourceLine): DiagramEvent {
317
- const { end, attrs } = trailingAttrs(source, 0);
318
- const edge = /^([^\s:]+)\s*->\s*([^\s:]+)(?:\s*:\s*(.+))?$/d.exec(source.text.slice(0, end));
319
- if (!edge?.indices?.[1] || !edge.indices[2]) throw fail(source, 0, m.edgeShape);
320
- const from = boxAt(source, edge[1] ?? "", edge.indices[1][0]);
321
- const to = boxAt(source, edge[2] ?? "", edge.indices[2][0]);
322
- let carry: Carry | undefined;
323
- let labelSide: LabelSide | undefined;
324
- for (const attr of attrs) {
325
- const kind = valueOf("carry", attr.key, lang);
326
- const label = valueOf("attr", attr.key, lang) === "label";
327
- if (kind !== undefined) {
328
- if (carry) throw fail(source, attr.at, m.carryTwice);
329
- if (kind === "tag") {
330
- if (attr.value === "") throw fail(source, attr.at, m.tagNeedsValue);
331
- carry = { kind, text: attr.value };
332
- } else {
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
- ...(edge[3] !== undefined ? { label: edge[3] } : {}),
350
- carry: carry ?? { kind: "envelope" },
351
- ...(labelSide ? { labelSide } : {}),
493
+ ...(label !== undefined ? { label } : {}),
494
+ carry,
495
+ ...(side ? { labelSide: side.value as LabelSide } : {}),
352
496
  };
353
497
  }
354
498
 
355
- function parseStripValue(source: SourceLine): DiagramEvent {
356
- if (!strip) throw fail(source, 0, m.stripUndeclared);
357
- const start = new RegExp(`^${escape(wordOf("head", "strip", lang))}\\s+`).exec(source.text)?.[0].length ?? 0;
358
- const { end, attrs } = trailingAttrs(source, start);
359
- if (attrs.length > 1) throw fail(source, attrs[1]?.at ?? 0, m.stripTagOnce);
360
- const rest = source.text.slice(start, end);
361
- const assigned = /^(.+?)\s*=\s*(.+)$/.exec(rest);
362
- const field = (assigned ? assigned[1] : rest)?.trim() ?? "";
363
- if (!strip.fields.includes(field)) throw fail(source, start, m.noSuchField(field));
364
- const tag = attrs[0]?.raw;
365
- if (!assigned && tag === undefined) throw fail(source, start, m.stripNeedsValue);
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
- ...(assigned ? { value: assigned[2] ?? "" } : {}),
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 }] };