chaffjs 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.
Files changed (215) hide show
  1. package/README.md +11 -4
  2. package/dist/adapter-load.d.ts +12 -1
  3. package/dist/adapter-load.d.ts.map +1 -1
  4. package/dist/adapter-load.js +37 -13
  5. package/dist/adapter-load.js.map +1 -1
  6. package/dist/address-chain.d.ts +9 -0
  7. package/dist/address-chain.d.ts.map +1 -0
  8. package/dist/address-chain.js +105 -0
  9. package/dist/address-chain.js.map +1 -0
  10. package/dist/cli-args.d.ts +8 -0
  11. package/dist/cli-args.d.ts.map +1 -0
  12. package/dist/cli-args.js +8 -0
  13. package/dist/cli-args.js.map +1 -0
  14. package/dist/cli-text.d.ts +26 -0
  15. package/dist/cli-text.d.ts.map +1 -0
  16. package/dist/cli-text.js +155 -0
  17. package/dist/cli-text.js.map +1 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +94 -87
  21. package/dist/cli.js.map +1 -1
  22. package/dist/commands/cite.d.ts +2 -1
  23. package/dist/commands/cite.d.ts.map +1 -1
  24. package/dist/commands/cite.js +39 -18
  25. package/dist/commands/cite.js.map +1 -1
  26. package/dist/commands/eval.d.ts.map +1 -1
  27. package/dist/commands/eval.js +2 -1
  28. package/dist/commands/eval.js.map +1 -1
  29. package/dist/commands/feedback.d.ts +35 -0
  30. package/dist/commands/feedback.d.ts.map +1 -0
  31. package/dist/commands/feedback.js +157 -0
  32. package/dist/commands/feedback.js.map +1 -0
  33. package/dist/commands/skill.d.ts +22 -0
  34. package/dist/commands/skill.d.ts.map +1 -0
  35. package/dist/commands/skill.js +68 -0
  36. package/dist/commands/skill.js.map +1 -0
  37. package/dist/commands/test.d.ts.map +1 -1
  38. package/dist/commands/test.js +3 -2
  39. package/dist/commands/test.js.map +1 -1
  40. package/dist/commands/tree.d.ts +11 -1
  41. package/dist/commands/tree.d.ts.map +1 -1
  42. package/dist/commands/tree.js +21 -7
  43. package/dist/commands/tree.js.map +1 -1
  44. package/dist/config/by-path.d.ts +5 -1
  45. package/dist/config/by-path.d.ts.map +1 -1
  46. package/dist/config/by-path.js +1 -4
  47. package/dist/config/by-path.js.map +1 -1
  48. package/dist/config/load.d.ts +2 -0
  49. package/dist/config/load.d.ts.map +1 -1
  50. package/dist/config/load.js +3 -1
  51. package/dist/config/load.js.map +1 -1
  52. package/dist/config/rule-problems.d.ts +2 -1
  53. package/dist/config/rule-problems.d.ts.map +1 -1
  54. package/dist/config/rule-problems.js +17 -6
  55. package/dist/config/rule-problems.js.map +1 -1
  56. package/dist/config/write.d.ts.map +1 -1
  57. package/dist/config/write.js +29 -9
  58. package/dist/config/write.js.map +1 -1
  59. package/dist/detectors/char-shape.d.ts.map +1 -1
  60. package/dist/detectors/char-shape.js +3 -2
  61. package/dist/detectors/char-shape.js.map +1 -1
  62. package/dist/document.d.ts +2 -2
  63. package/dist/document.d.ts.map +1 -1
  64. package/dist/document.js +4 -2
  65. package/dist/document.js.map +1 -1
  66. package/dist/feedback/draft.d.ts +28 -0
  67. package/dist/feedback/draft.d.ts.map +1 -0
  68. package/dist/feedback/draft.js +66 -0
  69. package/dist/feedback/draft.js.map +1 -0
  70. package/dist/feedback/excerpt.d.ts +10 -0
  71. package/dist/feedback/excerpt.d.ts.map +1 -0
  72. package/dist/feedback/excerpt.js +20 -0
  73. package/dist/feedback/excerpt.js.map +1 -0
  74. package/dist/genre.d.ts +1 -1
  75. package/dist/genre.d.ts.map +1 -1
  76. package/dist/genre.js +3 -3
  77. package/dist/genre.js.map +1 -1
  78. package/dist/init.d.ts +2 -1
  79. package/dist/init.d.ts.map +1 -1
  80. package/dist/init.js +62 -13
  81. package/dist/init.js.map +1 -1
  82. package/dist/legal-address.d.ts +2 -0
  83. package/dist/legal-address.d.ts.map +1 -0
  84. package/dist/legal-address.js +16 -0
  85. package/dist/legal-address.js.map +1 -0
  86. package/dist/orthography.d.ts.map +1 -1
  87. package/dist/orthography.js +15 -0
  88. package/dist/orthography.js.map +1 -1
  89. package/dist/plugin.d.ts +94 -0
  90. package/dist/plugin.d.ts.map +1 -1
  91. package/dist/profile/for-file.d.ts +7 -0
  92. package/dist/profile/for-file.d.ts.map +1 -0
  93. package/dist/profile/for-file.js +6 -0
  94. package/dist/profile/for-file.js.map +1 -0
  95. package/dist/profile/load.d.ts +4 -0
  96. package/dist/profile/load.d.ts.map +1 -0
  97. package/dist/profile/load.js +25 -0
  98. package/dist/profile/load.js.map +1 -0
  99. package/dist/profile/parse.d.ts +19 -0
  100. package/dist/profile/parse.d.ts.map +1 -0
  101. package/dist/profile/parse.js +82 -0
  102. package/dist/profile/parse.js.map +1 -0
  103. package/dist/profile/select.d.ts +22 -0
  104. package/dist/profile/select.d.ts.map +1 -0
  105. package/dist/profile/select.js +21 -0
  106. package/dist/profile/select.js.map +1 -0
  107. package/dist/reasons.d.ts +16 -0
  108. package/dist/reasons.d.ts.map +1 -0
  109. package/dist/reasons.js +31 -0
  110. package/dist/reasons.js.map +1 -0
  111. package/dist/render/explain.d.ts.map +1 -1
  112. package/dist/render/explain.js +36 -12
  113. package/dist/render/explain.js.map +1 -1
  114. package/dist/render/friendly.d.ts.map +1 -1
  115. package/dist/render/friendly.js +32 -9
  116. package/dist/render/friendly.js.map +1 -1
  117. package/dist/render/rules-json.d.ts.map +1 -1
  118. package/dist/render/rules-json.js +47 -27
  119. package/dist/render/rules-json.js.map +1 -1
  120. package/dist/render/summary.d.ts +3 -2
  121. package/dist/render/summary.d.ts.map +1 -1
  122. package/dist/render/summary.js +24 -9
  123. package/dist/render/summary.js.map +1 -1
  124. package/dist/render/suppressions.d.ts +2 -1
  125. package/dist/render/suppressions.d.ts.map +1 -1
  126. package/dist/render/suppressions.js +34 -10
  127. package/dist/render/suppressions.js.map +1 -1
  128. package/dist/run.d.ts.map +1 -1
  129. package/dist/run.js +41 -12
  130. package/dist/run.js.map +1 -1
  131. package/dist/structure/build.d.ts +2 -1
  132. package/dist/structure/build.d.ts.map +1 -1
  133. package/dist/structure/build.js +102 -9
  134. package/dist/structure/build.js.map +1 -1
  135. package/dist/structure/cite.d.ts.map +1 -1
  136. package/dist/structure/cite.js +3 -1
  137. package/dist/structure/cite.js.map +1 -1
  138. package/dist/structure/issues.d.ts +5 -2
  139. package/dist/structure/issues.d.ts.map +1 -1
  140. package/dist/structure/issues.js +35 -10
  141. package/dist/structure/issues.js.map +1 -1
  142. package/dist/structure/numbering.d.ts.map +1 -1
  143. package/dist/structure/numbering.js +11 -6
  144. package/dist/structure/numbering.js.map +1 -1
  145. package/dist/structure/of.d.ts +2 -1
  146. package/dist/structure/of.d.ts.map +1 -1
  147. package/dist/structure/of.js +7 -1
  148. package/dist/structure/of.js.map +1 -1
  149. package/dist/structure/relative-find.d.ts +6 -0
  150. package/dist/structure/relative-find.d.ts.map +1 -0
  151. package/dist/structure/relative-find.js +179 -0
  152. package/dist/structure/relative-find.js.map +1 -0
  153. package/dist/structure/relative-resolve.d.ts +3 -0
  154. package/dist/structure/relative-resolve.d.ts.map +1 -0
  155. package/dist/structure/relative-resolve.js +175 -0
  156. package/dist/structure/relative-resolve.js.map +1 -0
  157. package/dist/structure/unread.d.ts +8 -0
  158. package/dist/structure/unread.d.ts.map +1 -0
  159. package/dist/structure/unread.js +34 -0
  160. package/dist/structure/unread.js.map +1 -0
  161. package/dist/ui.d.ts +14 -0
  162. package/dist/ui.d.ts.map +1 -0
  163. package/dist/ui.js +14 -0
  164. package/dist/ui.js.map +1 -0
  165. package/dist/watch.d.ts +1 -1
  166. package/dist/watch.d.ts.map +1 -1
  167. package/dist/watch.js +2 -2
  168. package/dist/watch.js.map +1 -1
  169. package/package.json +5 -3
  170. package/profiles/statute.yaml +62 -0
  171. package/skills/chaff/SKILL.md +81 -0
  172. package/src/adapter-load.ts +41 -10
  173. package/src/address-chain.ts +113 -0
  174. package/src/cli-args.ts +9 -0
  175. package/src/cli-text.ts +186 -0
  176. package/src/cli.ts +111 -90
  177. package/src/commands/cite.ts +51 -18
  178. package/src/commands/eval.ts +2 -1
  179. package/src/commands/feedback.ts +210 -0
  180. package/src/commands/skill.ts +74 -0
  181. package/src/commands/test.ts +3 -2
  182. package/src/commands/tree.ts +30 -8
  183. package/src/config/by-path.ts +11 -3
  184. package/src/config/load.ts +5 -1
  185. package/src/config/rule-problems.ts +24 -5
  186. package/src/config/write.ts +33 -9
  187. package/src/detectors/char-shape.ts +5 -4
  188. package/src/document.ts +11 -3
  189. package/src/feedback/draft.ts +102 -0
  190. package/src/feedback/excerpt.ts +23 -0
  191. package/src/genre.ts +4 -4
  192. package/src/init.ts +74 -15
  193. package/src/orthography.ts +15 -0
  194. package/src/plugin.ts +88 -0
  195. package/src/profile/for-file.ts +11 -0
  196. package/src/profile/load.ts +26 -0
  197. package/src/profile/parse.ts +106 -0
  198. package/src/profile/select.ts +35 -0
  199. package/src/reasons.ts +49 -0
  200. package/src/render/explain.ts +46 -12
  201. package/src/render/friendly.ts +42 -10
  202. package/src/render/rules-json.ts +35 -9
  203. package/src/render/summary.ts +34 -9
  204. package/src/render/suppressions.ts +45 -10
  205. package/src/run.ts +44 -11
  206. package/src/structure/build.ts +128 -12
  207. package/src/structure/cite.ts +3 -1
  208. package/src/structure/issues.ts +42 -15
  209. package/src/structure/numbering.ts +12 -7
  210. package/src/structure/of.ts +15 -3
  211. package/src/structure/relative-find.ts +224 -0
  212. package/src/structure/relative-resolve.ts +209 -0
  213. package/src/structure/unread.ts +39 -0
  214. package/src/ui.ts +23 -0
  215. package/src/watch.ts +2 -2
@@ -1,7 +1,10 @@
1
1
  import type { Heading } from "../document.ts";
2
2
  import { atxHeadingText, headingText } from "../heading-text.ts";
3
+ import { addressSpans } from "../address-chain.ts";
4
+ import { relativeMentions } from "./relative-find.ts";
5
+ import { resolveRelative } from "./relative-resolve.ts";
3
6
  import { maskSpans } from "../mask.ts";
4
- import type { Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
7
+ import type { DocumentProfile, Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
5
8
  import { lineNumberAt, linesOf, type Line } from "./lines.ts";
6
9
  import { dottedNumber } from "./universal.ts";
7
10
 
@@ -15,6 +18,9 @@ type Draft = {
15
18
  readonly attrs: Readonly<Record<string, string | number>>;
16
19
  readonly children: Draft[];
17
20
  readonly ordinal?: number | undefined;
21
+ readonly level?: number | undefined;
22
+ readonly ordinalTo?: number | undefined;
23
+ readonly numbering?: string | undefined;
18
24
  };
19
25
 
20
26
  /** 開いている節点。rank の大きいものほど内側。 */
@@ -30,6 +36,13 @@ type State = {
30
36
  readonly stack: Frame[];
31
37
  /** 見出しの深さごとの通し番号。番号の無い見出しの番地 h2.1 を作る。 */
32
38
  readonly headingCounts: number[];
39
+ /** opensDefinitionScope の行を含んだまとまり(ふつうは条)。ここに入る定義は範囲の中でだけ比べる。 */
40
+ readonly scopedArticles: Set<Draft>;
41
+ /** 範囲が条より広い(「In this Part—」)条と、その広いまとまり。その条の定義は、広いまとまりの中で比べる。 */
42
+ readonly widerScopes: Map<Draft, Draft>;
43
+ readonly profile: DocumentProfile | undefined;
44
+ /** 見出しだけの行(文書の種類の caption)の行番号と、その言葉。すぐ次の行の条が見出しに使う。 */
45
+ readonly captions: Map<number, string>;
33
46
  };
34
47
 
35
48
  const draftOf = (kind: StructureKind, address: string, line: Line, attrs: Readonly<Record<string, string | number>>): Draft => ({
@@ -88,7 +101,13 @@ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingD
88
101
  const rank = headingDepth ?? NUMBERED_RANK + numbered.depth;
89
102
  // 番地は、自分と同じか内側を閉じてから決める。閉じる前だと、同じ深さの前の条を親と取り違える。
90
103
  while (state.stack.length > 1 && top(state).rank >= rank) state.stack.pop();
91
- const draft = { ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs), ordinal: numbered.ordinal };
104
+ const draft = {
105
+ ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs),
106
+ ordinal: numbered.ordinal,
107
+ level: numbered.depth,
108
+ ordinalTo: numbered.ordinalTo,
109
+ numbering: numbered.numbering,
110
+ };
92
111
  open(state, { draft, rank, numbered });
93
112
  };
94
113
 
@@ -97,11 +116,30 @@ const openSection = (state: State, line: Line, heading: Heading): void => {
97
116
  open(state, { draft: draftOf("section", headingAddress(state, heading.depth), line, { heading: heading.text }), rank: heading.depth });
98
117
  };
99
118
 
100
- const LEAVES: readonly { readonly kind: StructureKind; readonly find: (patterns: StructurePatterns, text: string) => readonly Mention[] }[] = [
119
+ /** 番地(文書の種類が決める「前二項」など)の中の数は、数量ではない。 */
120
+ const outsideAddresses = (mentions: readonly Mention[], text: string, profile: DocumentProfile | undefined): readonly Mention[] => {
121
+ const spans = addressSpans(text, profile);
122
+ return spans.length === 0 ? mentions : mentions.filter((mention) => !spans.some((span) => span.start <= mention.start && mention.end <= span.end));
123
+ };
124
+
125
+ /** 番地を名指しした参照(第百五十七条第一項)の一部は、相対の参照ではない。 */
126
+ const withoutAbsolute = (relative: readonly Mention[], absolute: readonly Mention[]): readonly Mention[] =>
127
+ relative.filter((mention) => !absolute.some((other) => mention.start < other.end && other.start < mention.end));
128
+
129
+ type LeafFinder = (patterns: StructurePatterns, text: string, profile: DocumentProfile | undefined) => readonly Mention[];
130
+
131
+ const LEAVES: readonly { readonly kind: StructureKind; readonly find: LeafFinder }[] = [
101
132
  { kind: "definition", find: (patterns, text) => patterns.definitions(text) },
102
133
  { kind: "reference", find: (patterns, text) => patterns.references(text) },
134
+ {
135
+ kind: "reference",
136
+ find: (patterns, text, profile) => {
137
+ const absolute = patterns.references(text);
138
+ return withoutAbsolute(relativeMentions(text, profile, patterns.number, absolute), absolute);
139
+ },
140
+ },
103
141
  { kind: "obligation", find: (patterns, text) => patterns.obligations(text) },
104
- { kind: "quantity", find: (patterns, text) => patterns.quantities(text) },
142
+ { kind: "quantity", find: (patterns, text, profile) => outsideAddresses(patterns.quantities(text), text, profile) },
105
143
  { kind: "date", find: (patterns, text) => patterns.dates?.(text) ?? [] },
106
144
  ];
107
145
 
@@ -112,13 +150,54 @@ const universalNumber = (patterns: StructurePatterns, text: string, context: Num
112
150
  };
113
151
 
114
152
  /** 行の中の定義・参照・義務・数量を、いま開いている最も内側の節点の子にする。 */
153
+ const enclosingArticle = (state: State): Draft | undefined => [...state.stack].reverse().find((frame) => frame.draft.kind === "article")?.draft;
154
+
155
+ /** 条より広い範囲(「In this Part」)なら、その深さの開いているまとまり。開いていなければ(Part の見出しの無い文書)無い。 */
156
+ const widerScope = (state: State, patterns: StructurePatterns, text: string): Draft | undefined => {
157
+ const depth = patterns.definitionScopeDepth?.(text);
158
+ return depth === undefined ? undefined : [...state.stack].reverse().find((frame) => frame.numbered?.depth === depth)?.draft;
159
+ };
160
+
161
+ /** 範囲を宣言した行を持つまとまり。条の中なら条、条の外(Markdown の見出しの下、Part の直下)なら開いている一番内側のもの。 */
162
+ const holderOf = (state: State): Draft => enclosingArticle(state) ?? top(state).draft;
163
+
164
+ /**
165
+ * 定義の範囲。範囲を宣言した行(In this section— / In this Part—)を含むまとまりの定義が対象。宣言が Part なら、比べるのは
166
+ * その Part の中(within に番地を残す)。条の外で宣言したものは、そのまとまりの中。宣言の無いところの定義は、文書全体で比べる。
167
+ */
168
+ const scopeOf = (state: State, patterns: StructurePatterns, text: string): Readonly<Record<string, string>> => {
169
+ const holder = holderOf(state);
170
+ if (patterns.opensDefinitionScope?.(text) === true) {
171
+ state.scopedArticles.add(holder);
172
+ const wider = widerScope(state, patterns, text);
173
+ if (wider !== undefined) state.widerScopes.set(holder, wider);
174
+ }
175
+ if (!state.scopedArticles.has(holder)) return {};
176
+ const within = state.widerScopes.get(holder) ?? (holder.kind === "article" ? undefined : holder);
177
+ return within === undefined ? { scope: "local" } : { scope: "local", within: within.address };
178
+ };
179
+
115
180
  const addLeaves = (state: State, patterns: StructurePatterns, line: Line, text: string, offset: number): void => {
116
181
  const parent = top(state).draft;
182
+ const scope = scopeOf(state, patterns, text);
117
183
  const leaves = LEAVES.flatMap(({ kind, find }) =>
118
- find(patterns, text).map((mention) => ({ kind, start: line.start + offset + mention.start, end: line.start + offset + mention.end, attrs: mention.attrs })),
184
+ find(patterns, text, state.profile).map((mention) => ({
185
+ kind,
186
+ start: line.start + offset + mention.start,
187
+ end: line.start + offset + mention.end,
188
+ attrs: mention.attrs,
189
+ })),
119
190
  ).sort((left, right) => left.start - right.start);
120
191
  leaves.forEach((leaf) =>
121
- parent.children.push({ kind: leaf.kind, address: "", start: leaf.start, end: leaf.end, line: line.number, attrs: leaf.attrs, children: [] }),
192
+ parent.children.push({
193
+ kind: leaf.kind,
194
+ address: "",
195
+ start: leaf.start,
196
+ end: leaf.end,
197
+ line: line.number,
198
+ attrs: leaf.kind === "definition" ? { ...leaf.attrs, ...scope } : leaf.attrs,
199
+ children: [],
200
+ }),
122
201
  );
123
202
  };
124
203
 
@@ -136,6 +215,9 @@ const freeze = (draft: Draft): StructureNode => ({
136
215
  attrs: draft.attrs,
137
216
  children: draft.children.map(freeze),
138
217
  ...(draft.ordinal === undefined ? {} : { ordinal: draft.ordinal }),
218
+ ...(draft.level === undefined ? {} : { level: draft.level }),
219
+ ...(draft.ordinalTo === undefined ? {} : { ordinalTo: draft.ordinalTo }),
220
+ ...(draft.numbering === undefined ? {} : { numbering: draft.numbering }),
139
221
  });
140
222
 
141
223
  /** Markdown から取った手がかり。見出しと、中を読まない範囲(コード)。.txt はどちらも空。 */
@@ -143,7 +225,13 @@ export type Outline = { readonly headings: readonly Heading[]; readonly opaque:
143
225
 
144
226
  export const NO_OUTLINE: Outline = { headings: [], opaque: [] };
145
227
 
146
- export type StructureInput = { readonly path: string; readonly source: string; readonly language: string; readonly outline: Outline };
228
+ export type StructureInput = {
229
+ readonly path: string;
230
+ readonly source: string;
231
+ readonly language: string;
232
+ readonly outline: Outline;
233
+ readonly profile?: DocumentProfile | undefined;
234
+ };
147
235
 
148
236
  /** 見出しのある行を、行番号から引けるようにする。見出しごとに全行を探し直さない。 */
149
237
  const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): Map<number, Heading> =>
@@ -156,7 +244,6 @@ const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): M
156
244
  }),
157
245
  );
158
246
 
159
- /** 見出しの行から「#」の印を外した文字列。コードを覆った後の行から取るので、見出しの中のコードも読まない。 */
160
247
  /**
161
248
  * 見出しの行から見出しの言葉を取る。コードを覆った後の行から取るので、見出しの中のコードも読まない。
162
249
  * ATX(行頭が #)なら印と閉じの # を外す。setext(下線で書く見出し)の末尾の # は言葉なので残す。
@@ -167,11 +254,30 @@ const headingLineText = (text: string): string => {
167
254
  return atxHeadingText(line.replace(/^#{1,6}(?=[ \t]|$)/u, "").trim());
168
255
  };
169
256
 
257
+ const captionPatterns = new WeakMap<DocumentProfile, RegExp | undefined>();
258
+
259
+ /** 文書の種類が決めた見出しだけの行なら、その言葉(最初の括弧の中、括弧が無ければ行全体)。 */
260
+ const captionOf = (profile: DocumentProfile | undefined, text: string): string | undefined => {
261
+ if (profile === undefined) return undefined;
262
+ if (!captionPatterns.has(profile)) captionPatterns.set(profile, profile.caption === undefined ? undefined : new RegExp(profile.caption, "u"));
263
+ const found = captionPatterns.get(profile)?.exec(text);
264
+ const words = (found?.[1] ?? found?.[0])?.trim();
265
+ return words === "" ? undefined : words;
266
+ };
267
+
268
+ /** 見出しの無い条のすぐ前の行が見出しだけの行なら、それを条の見出しにする。 */
269
+ const withCaption = (state: State, line: Line, numbered: NumberedLine | undefined): NumberedLine | undefined => {
270
+ const caption = numbered?.kind === "article" && numbered.heading === "" ? state.captions.get(line.number - 1) : undefined;
271
+ return numbered === undefined || caption === undefined ? numbered : { ...numbered, heading: caption };
272
+ };
273
+
170
274
  const readLine = (state: State, patterns: StructurePatterns, line: Line, heading: Heading | undefined): void => {
171
275
  const text = heading === undefined ? line.text : headingLineText(line.text);
172
276
  const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
173
277
  const context = { open: openNumbers, isHeading: heading !== undefined };
174
- const numbered = patterns.numbered(text, context) ?? universalNumber(patterns, text, context);
278
+ const numbered = withCaption(state, line, patterns.numbered(text, context) ?? universalNumber(patterns, text, context));
279
+ const caption = numbered === undefined && heading === undefined ? captionOf(state.profile, text) : undefined;
280
+ if (caption !== undefined) state.captions.set(line.number, caption);
175
281
  // 番号付きの見出しも見出しの通し番号を進める。進めないと、その下の「### 詳細」が前の見出しの番地を名乗る。
176
282
  if (numbered !== undefined && heading !== undefined) headingAddress(state, heading.depth);
177
283
  if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth);
@@ -193,13 +299,23 @@ export const buildTree = (input: StructureInput, patterns: StructurePatterns): S
193
299
  // 文中の `第99条` は参照として拾われない。位置は元の文書のまま。
194
300
  const lines = linesOf(maskSpans(input.source, outline.opaque));
195
301
  const headings = headingsByLine(lines, outline.headings);
196
- const doc = draftOf("doc", "", { text: "", start: 0, number: 1 }, { path: input.path, language: input.language });
302
+ const attrs = { path: input.path, language: input.language, ...(input.profile === undefined ? {} : { profile: input.profile.id }) };
303
+ const doc = draftOf("doc", "", { text: "", start: 0, number: 1 }, attrs);
197
304
  doc.end = input.source.length;
198
- const state: State = { stack: [{ draft: doc, rank: 0 }], headingCounts: [] };
305
+ const state: State = {
306
+ stack: [{ draft: doc, rank: 0 }],
307
+ headingCounts: [],
308
+ scopedArticles: new Set(),
309
+ widerScopes: new Map(),
310
+ profile: input.profile,
311
+ captions: new Map(),
312
+ };
199
313
  lines.forEach((line) => {
200
314
  if (line.text.trim() !== "") readLine(state, patterns, line, headings.get(line.number));
201
315
  // コードの行は覆って読まないが、開いている節の中身ではある。節の最後にコードブロックがあっても、範囲をそこまで伸ばす。
202
316
  else if (input.source.slice(line.start, line.start + line.text.length).trim() !== "") extend(state, line.start + line.text.length);
203
317
  });
204
- return freeze(doc);
318
+ const tree = freeze(doc);
319
+ const relative = input.profile?.relative;
320
+ return relative === undefined ? tree : resolveRelative(tree, relative.implicitFirst);
205
321
  };
@@ -100,7 +100,9 @@ export const checkCitations = (source: string, tree: StructureNode, citations: r
100
100
  const whole = normalize(source);
101
101
  const starts = lineStarts(source);
102
102
  return citations.map((citation): CitationResult => {
103
- const candidates = byAddress.get(citation.address) ?? [];
103
+ // 第 1 項に番号の無い書き方では、「12.1」は第12条そのもの。参照の fallback と同じに読む。
104
+ const firstParagraph = citation.address.endsWith(".1") ? byAddress.get(citation.address.slice(0, -2)) : undefined;
105
+ const candidates = byAddress.get(citation.address) ?? firstParagraph ?? [];
104
106
  const first = candidates[0];
105
107
  if (first === undefined) return { citation, status: "missing-address" };
106
108
  const key = normalize(citation.quote).text;
@@ -34,30 +34,57 @@ const resolves = (node: StructureNode, addresses: ReadonlySet<string>): boolean
34
34
 
35
35
  /**
36
36
  * 参照の番地が木に無い。他の文書の名前が付いた参照(民法第709条、Section 9 of the Master Agreement)は引かない。
37
- * 番号付きのまとまりを 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
37
+ * 条を 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
38
38
  */
39
39
  export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
40
40
  const nodes = inDocumentOrder(tree);
41
+ // 参照は条を指す。条を一つも持たない文書(契約書に付ける承諾書のひな形など)の「契約書第6条」は、別の文書の条。
42
+ if (!nodes.some((node) => node.kind === "article")) return [];
41
43
  const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
42
- if (addresses.size === 0) return [];
44
+ // Section で組んだ法令が "Articles 13 to 21 of the UK GDPR" と別の文書の Article を名指ししていれば、名の無い "Article 6(3)" もそちら。
45
+ // 名指しがあれば、名の無い "Article 9" が書き間違いか向こうの条かは区別できないので黙る。名指しが無い文書では報告する。
46
+ const numbering = (node: StructureNode): unknown => node.attrs["numbering"];
47
+ const numberings = new Set(nodes.flatMap((node) => (node.kind === "article" && node.numbering !== undefined ? [node.numbering] : [])));
48
+ const citedElsewhere = new Set(nodes.filter((node) => node.kind === "reference" && node.attrs["document"] !== undefined).map(numbering));
49
+ const otherNumbering = (node: StructureNode): boolean =>
50
+ numberings.size > 0 && typeof numbering(node) === "string" && !numberings.has(String(numbering(node))) && citedElsewhere.has(numbering(node));
43
51
  return nodes
44
- .filter((node) => node.kind === "reference" && node.attrs["document"] === undefined)
52
+ .filter((node) => node.kind === "reference" && node.attrs["document"] === undefined && !otherNumbering(node))
45
53
  .filter((node) => !resolves(node, addresses))
46
54
  .map((node) => ({ offset: node.span.start, values: { label: textOf(node, "label"), target: textOf(node, "target") } }));
47
55
  };
48
56
 
49
- /** 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。 */
57
+ type Definition = { readonly node: StructureNode; readonly article: string };
58
+
59
+ /** 定義を文書の順に、それが置かれた条の番地と一緒に並べる。範囲を限った定義は、その条の中でだけ比べるため。 */
60
+ const definitionsInOrder = (tree: StructureNode): Definition[] => {
61
+ const found: Definition[] = [];
62
+ const pending: Definition[] = [{ node: tree, article: "" }];
63
+ while (pending.length > 0) {
64
+ const current = pending.pop();
65
+ if (current === undefined) break;
66
+ if (current.node.kind === "definition") found.push(current);
67
+ const article = current.node.kind === "article" ? current.node.address : current.article;
68
+ [...current.node.children].reverse().forEach((child) => pending.push({ node: child, article }));
69
+ }
70
+ return found;
71
+ };
72
+
73
+ /**
74
+ * 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。
75
+ * 範囲を限った定義(この条において「X」とは)は、同じ条の中でだけ比べる。別の条で定義し直すのは正しい書き方。
76
+ */
50
77
  export const duplicateDefinitions = (tree: StructureNode): StructureIssue[] => {
51
78
  const first = new Map<string, StructureNode>();
52
- return inDocumentOrder(tree)
53
- .filter((node) => node.kind === "definition")
54
- .flatMap((node) => {
55
- const term = textOf(node, "term");
56
- const earlier = first.get(term);
57
- if (earlier === undefined) {
58
- first.set(term, node);
59
- return [];
60
- }
61
- return [{ offset: node.span.start, values: { term, first: earlier.line } }];
62
- });
79
+ return definitionsInOrder(tree).flatMap(({ node, article }) => {
80
+ const term = textOf(node, "term");
81
+ const within = node.attrs["within"] === undefined ? article : textOf(node, "within");
82
+ const key = node.attrs["scope"] === "local" ? `${within}\u0000${term}` : term;
83
+ const earlier = first.get(key);
84
+ if (earlier === undefined) {
85
+ first.set(key, node);
86
+ return [];
87
+ }
88
+ return [{ offset: node.span.start, values: { term, first: earlier.line } }];
89
+ });
63
90
  };
@@ -3,17 +3,21 @@ import { inDocumentOrder, type StructureIssue } from "./issues.ts";
3
3
 
4
4
  const labelOf = (node: StructureNode): string => String(node.attrs["label"] ?? node.address);
5
5
 
6
- type Ordered = { readonly node: StructureNode; readonly ordinal: number };
6
+ type Ordered = { readonly node: StructureNode; readonly ordinal: number; readonly last: number };
7
7
 
8
- /** 同じ親の子のうち、番号の付いたものを種類ごとに分ける。条と項は別の並び。 */
8
+ /**
9
+ * 同じ親の子のうち、番号の付いたものを種類と深さごとに分ける。条と項は別の並び、項と号も別の並び。
10
+ * 法令の第 1 項は番号が無いので、その号(一、二)と第 2 項(2)は同じ条の直下に並ぶ。深さで分けないと「二の次が2」に見える。
11
+ */
9
12
  const sequencesOf = (parent: StructureNode): Ordered[][] => {
10
13
  const groups = new Map<string, Ordered[]>();
11
14
  parent.children.forEach((node) => {
12
15
  if (node.ordinal === undefined) return;
16
+ const key = `${node.kind}/${String(node.level ?? "")}`;
13
17
  // 配列を作り直さずに足す。条が何千もある法令で二乗にしない。
14
- const group = groups.get(node.kind) ?? [];
15
- group.push({ node, ordinal: node.ordinal });
16
- groups.set(node.kind, group);
18
+ const group = groups.get(key) ?? [];
19
+ group.push({ node, ordinal: node.ordinal, last: node.ordinalTo ?? node.ordinal });
20
+ groups.set(key, group);
17
21
  });
18
22
  return [...groups.values()];
19
23
  };
@@ -23,11 +27,12 @@ const breaksIn = (sequence: readonly Ordered[]): StructureIssue[] =>
23
27
  sequence.flatMap((current, index) => {
24
28
  const previous = sequence[index - 1];
25
29
  // 1 に戻ったら新しい並び。一つの条に (a)(b) の箇条書きが二つある、附則が第1条から振り直す、のどちらも誤りではない。
26
- if (previous === undefined || current.ordinal === previous.ordinal + 1 || current.ordinal === 1) return [];
30
+ // 範囲をまとめた行(第四十三条から第五十五条まで)の次は、範囲の最後から数える。
31
+ if (previous === undefined || current.ordinal === previous.last + 1 || current.ordinal === 1) return [];
27
32
  return [
28
33
  {
29
34
  offset: current.node.span.start,
30
- values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.ordinal + 1, found: current.ordinal },
35
+ values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.last + 1, found: current.ordinal },
31
36
  },
32
37
  ];
33
38
  });
@@ -1,8 +1,14 @@
1
1
  import { markdownOutline } from "../document.ts";
2
- import type { StructureNode, StructurePatterns } from "../plugin.ts";
2
+ import type { DocumentProfile, StructureNode, StructurePatterns } from "../plugin.ts";
3
3
  import { buildTree, NO_OUTLINE } from "./build.ts";
4
4
 
5
- export type SourceInput = { readonly path: string; readonly source: string; readonly language: string; readonly markdown: boolean };
5
+ export type SourceInput = {
6
+ readonly path: string;
7
+ readonly source: string;
8
+ readonly language: string;
9
+ readonly markdown: boolean;
10
+ readonly profile?: DocumentProfile | undefined;
11
+ };
6
12
 
7
13
  /**
8
14
  * 文字列から木を作る。Markdown なら見出しとコードの範囲を先に取る。
@@ -10,6 +16,12 @@ export type SourceInput = { readonly path: string; readonly source: string; read
10
16
  */
11
17
  export const buildStructure = (input: SourceInput, patterns: StructurePatterns): StructureNode =>
12
18
  buildTree(
13
- { path: input.path, source: input.source, language: input.language, outline: input.markdown ? markdownOutline(input.source) : NO_OUTLINE },
19
+ {
20
+ path: input.path,
21
+ source: input.source,
22
+ language: input.language,
23
+ outline: input.markdown ? markdownOutline(input.source) : NO_OUTLINE,
24
+ profile: input.profile,
25
+ },
14
26
  patterns,
15
27
  );
@@ -0,0 +1,224 @@
1
+ import type { DocumentProfile, Mention, RelativeVocabulary, Span } from "../plugin.ts";
2
+
3
+ /**
4
+ * 前条・次項・同号・本条・前各項・前二項・前条第二項、条を書かない「第一項」を行の中から探す。語は文書の種類(profiles/*.yaml)が決める。
5
+ * ここでは番地を決めず、何をどれだけ指すかだけを attrs に残す。番地は木ができてから relative-resolve.ts が決める。
6
+ */
7
+ type RelativeWay = "before" | "after" | "same" | "current" | "continue";
8
+
9
+ type NumberReader = (text: string) => number | undefined;
10
+
11
+ const escape = (word: string): string => word.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
12
+
13
+ const oneOf = (words: readonly string[]): string => (words.length === 0 ? "(?!)" : words.map(escape).join("|"));
14
+
15
+ type Compiled = {
16
+ readonly named: RegExp;
17
+ readonly bare: RegExp | undefined;
18
+ readonly suffix: RegExp;
19
+ readonly notAfter: RegExp | undefined;
20
+ readonly joins: RegExp;
21
+ readonly ways: ReadonlyMap<string, RelativeWay>;
22
+ };
23
+
24
+ const waysOf = (vocabulary: RelativeVocabulary): Map<string, RelativeWay> =>
25
+ new Map<string, RelativeWay>([
26
+ ...vocabulary.before.map((word): [string, RelativeWay] => [word, "before"]),
27
+ ...vocabulary.after.map((word): [string, RelativeWay] => [word, "after"]),
28
+ ...vocabulary.same.map((word): [string, RelativeWay] => [word, "same"]),
29
+ ...vocabulary.current.map((word): [string, RelativeWay] => [word, "current"]),
30
+ ]);
31
+
32
+ /** 後ろの決まり: 番地の並びと同じく、つなぎの語を挟んで address_end。「前条件」の「前条」を参照にしない。 */
33
+ const endOf = (profile: DocumentProfile): string => {
34
+ const connectives = profile.connectives.length === 0 ? "" : `(?:${oneOf(profile.connectives)})*`;
35
+ return profile.addressEnd === undefined ? "" : `(?=${connectives}(?:${profile.addressEnd}))`;
36
+ };
37
+
38
+ const compile = (profile: DocumentProfile, vocabulary: RelativeVocabulary): Compiled => {
39
+ const units = oneOf(Object.keys(vocabulary.units));
40
+ const suffix = `${escape(vocabulary.suffixPrefix)}(?:${vocabulary.count})(?:${units})`;
41
+ const end = endOf(profile);
42
+ const start = vocabulary.notAfter === undefined ? "" : `(?<!${vocabulary.notAfter})`;
43
+ const way = oneOf([...vocabulary.before, ...vocabulary.after, ...vocabulary.same, ...vocabulary.current]);
44
+ const count = `${oneOf(vocabulary.every)}|${vocabulary.count}`;
45
+ const inside = `${escape(vocabulary.suffixPrefix)}(?:${vocabulary.count})(?:${oneOf(vocabulary.inside)})`;
46
+ return {
47
+ named: new RegExp(`${start}(?<way>${way})(?<count>${count})?(?<unit>${units})(?<suffix>(?:${suffix})*)${end}`, "gu"),
48
+ // 前の文字は見ない。語に続くもの(別表第一第一号)も見つけて、並びの続きを止めるのに使う。
49
+ bare: vocabulary.inside.length === 0 ? undefined : new RegExp(`(?<suffix>${inside}(?:${suffix})*)${end}`, "gu"),
50
+ suffix: new RegExp(`${escape(vocabulary.suffixPrefix)}(?<n>${vocabulary.count})(?<unit>${units})`, "gu"),
51
+ notAfter: vocabulary.notAfter === undefined ? undefined : new RegExp(`(?:${vocabulary.notAfter})$`, "u"),
52
+ joins: new RegExp(`(?:${[...vocabulary.joiners, ...profile.connectives.map(escape)].join("|") || "(?!)"})`, "gu"),
53
+ ways: waysOf(vocabulary),
54
+ };
55
+ };
56
+
57
+ const compiled = new WeakMap<DocumentProfile, Compiled>();
58
+
59
+ /** 「第二項第三号」を「2:2,3:3」(深さ:番号)にする。読めない数があれば undefined。 */
60
+ const suffixOf = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader): string | undefined => {
61
+ const parts = [...text.matchAll(patterns.suffix)].map((match) => {
62
+ const value = number(match.groups?.["n"] ?? "");
63
+ const depth = vocabulary.units[match.groups?.["unit"] ?? ""];
64
+ return value === undefined || depth === undefined ? undefined : `${String(depth)}:${String(value)}`;
65
+ });
66
+ return parts.includes(undefined) ? undefined : parts.join(",");
67
+ };
68
+
69
+ /** 「各」は前の全部(0)、数が無ければ 1。 */
70
+ const countOf = (count: string | undefined, vocabulary: RelativeVocabulary, number: NumberReader): number | undefined => {
71
+ if (count === undefined) return 1;
72
+ return vocabulary.every.includes(count) ? 0 : number(count);
73
+ };
74
+
75
+ type Substitution = NonNullable<RelativeVocabulary["substitution"]>;
76
+
77
+ /** 文字と、その位置(UTF-16 の単位で)。絵文字のような 2 単位の文字も 1 文字として読む。 */
78
+ const characters = (text: string): { readonly char: string; readonly at: number }[] => {
79
+ const found: { char: string; at: number }[] = [];
80
+ let at = 0;
81
+ for (const char of text) {
82
+ found.push({ char, at });
83
+ at += char.length;
84
+ }
85
+ return found;
86
+ };
87
+
88
+ /** 一番外側の括弧の範囲。閉じの無い開きは捨てる。 */
89
+ const outerQuotes = (text: string, quote: Substitution): Span[] => {
90
+ const spans: Span[] = [];
91
+ const opens: number[] = [];
92
+ characters(text).forEach(({ char, at }) => {
93
+ if (char === quote.open) opens.push(at);
94
+ const start = char === quote.close ? opens.pop() : undefined;
95
+ if (start !== undefined && opens.length === 0) spans.push({ start, end: at + char.length });
96
+ });
97
+ return spans;
98
+ };
99
+
100
+ /** 読み替えの括弧(閉じの直後か開きの直前に目印があるもの)の範囲。 */
101
+ const substitutionSpans = (text: string, quote: Substitution | undefined): Span[] =>
102
+ quote === undefined
103
+ ? []
104
+ : outerQuotes(text, quote).filter(
105
+ (span) => quote.after.some((marker) => text.startsWith(marker, span.end)) || quote.before.some((marker) => text.slice(0, span.start).endsWith(marker)),
106
+ );
107
+
108
+ /** 閉じた括弧書きを内側から外す。 */
109
+ const withoutAsides = (gap: string, aside: RelativeVocabulary["aside"]): string => {
110
+ if (aside === undefined) return gap;
111
+ const inner = new RegExp(`${escape(aside.open)}[^${escape(aside.open)}${escape(aside.close)}]*${escape(aside.close)}`, "gu");
112
+ const once = gap.replace(inner, "");
113
+ return once === gap ? gap : withoutAsides(once, aside);
114
+ };
115
+
116
+ /** あいだが、並びをつなぐもの・つなぎの語・閉じた括弧書き・空白だけか。 */
117
+ const onlyJoins = (gap: string, patterns: Compiled, vocabulary: RelativeVocabulary): boolean =>
118
+ withoutAsides(gap, vocabulary.aside).replace(patterns.joins, "").trim() === "";
119
+
120
+ const overlaps = (start: number, end: number, others: readonly Mention[]): boolean => others.some((other) => start < other.end && other.start < end);
121
+
122
+ /** 行き先を決められない番地。並びの続きを止めるためだけに覚え、参照にはしない。 */
123
+ const OPAQUE = "opaque";
124
+
125
+ /** すぐ前が not_after に当たる(「別表第一」の「一」に続く「第一号」)。 */
126
+ const glued = (text: string, start: number, patterns: Compiled): boolean => patterns.notAfter?.test(text.slice(0, start)) === true;
127
+
128
+ type Bare = { readonly start: number; readonly end: number; readonly label: string; readonly suffix: string; readonly first: number };
129
+
130
+ const bareCandidates = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader): Bare[] =>
131
+ patterns.bare === undefined
132
+ ? []
133
+ : [...text.matchAll(patterns.bare)].flatMap((match) => {
134
+ const suffix = suffixOf(match.groups?.["suffix"] ?? "", patterns, vocabulary, number);
135
+ const first = Number(suffix?.split(":")[0]);
136
+ return suffix === undefined || !Number.isInteger(first)
137
+ ? []
138
+ : [{ start: match.index, end: match.index + match[0].length, label: match[0], suffix, first }];
139
+ });
140
+
141
+ /** 並びの中での扱い: 前の参照の続きか、今いるところの中か、行き先を決められないか。 */
142
+ type BareWay = { readonly way: string; readonly continues?: Mention | undefined };
143
+
144
+ /** 並びの中での扱い: 前の参照の続きか、今いるところの中か、行き先を決められないか。続きなら、どの参照の続きか。 */
145
+ const wayOfBare = (text: string, bare: Bare, known: readonly Mention[], patterns: Compiled, vocabulary: RelativeVocabulary): BareWay => {
146
+ if (glued(text, bare.start, patterns)) return { way: OPAQUE };
147
+ const depth = openAsides(text, bare.start, vocabulary).length;
148
+ const sameDepth = known.filter((other) => other.end <= bare.start && openAsides(text, other.end, vocabulary).length === depth);
149
+ const previous = sameDepth.toSorted((left, right) => left.end - right.end).at(-1);
150
+ const joined = previous !== undefined && onlyJoins(text.slice(previous.end, bare.start), patterns, vocabulary) ? previous : undefined;
151
+ const continues = joined ?? ownerOfAside(text, bare.start, known, vocabulary);
152
+ if (continues === undefined) return { way: "current" };
153
+ return continues.attrs["relative"] === OPAQUE ? { way: OPAQUE } : { way: "continue", continues };
154
+ };
155
+
156
+ /** at の位置で開いたままの括弧書きの開きの位置。並びは同じ深さの参照どうしでつながる。 */
157
+ const openAsides = (text: string, at: number, vocabulary: RelativeVocabulary): number[] => {
158
+ const aside = vocabulary.aside;
159
+ const opens: number[] = [];
160
+ if (aside === undefined) return opens;
161
+ characters(text.slice(0, at)).forEach(({ char, at: position }) => {
162
+ if (char === aside.open) opens.push(position);
163
+ if (char === aside.close) opens.pop();
164
+ });
165
+ return opens;
166
+ };
167
+
168
+ /**
169
+ * 参照の直後に開いた括弧書きの中なら、その参照。「第二十七条(第四項を除き、第五項及び第六項の規定を…)」の番地は、どれも第二十七条の中。
170
+ */
171
+ const ownerOfAside = (text: string, at: number, known: readonly Mention[], vocabulary: RelativeVocabulary): Mention | undefined => {
172
+ const open = openAsides(text, at, vocabulary).at(-1);
173
+ return open === undefined ? undefined : known.find((other) => other.end === open);
174
+ };
175
+
176
+ /**
177
+ * 条を書かない「第一項」。すぐ前の参照とのあいだがつなぎの語だけなら、その参照の続き(continue)。
178
+ * そうでなければ、書いた場所を含む一つ上のまとまり(current)の中。
179
+ */
180
+ const bareMentions = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader, before: readonly Mention[]): Mention[] => {
181
+ const found: Mention[] = [];
182
+ bareCandidates(text, patterns, vocabulary, number).forEach((bare) => {
183
+ const known = [...before, ...found];
184
+ if (overlaps(bare.start, bare.end, known)) return;
185
+ const { way, continues } = wayOfBare(text, bare, known, patterns, vocabulary);
186
+ // 条を書かない番地は、その一つ上(level)ではなく、書いた単位(level + 1)から名指しする。「第一項」は「同条」の行き先を変えない。
187
+ // continues は、続きの元の参照がどれだけ前から始まるか。木の中で、その参照を位置で引く。
188
+ const back = continues === undefined ? {} : { continues: bare.start - continues.start };
189
+ const attrs = { label: bare.label, relative: way, level: bare.first - 1, count: 1, suffix: bare.suffix, names: bare.first, ...back };
190
+ found.push({ start: bare.start, end: bare.end, attrs });
191
+ });
192
+ return found.filter((mention) => mention.attrs["relative"] !== OPAQUE);
193
+ };
194
+
195
+ const namedMentions = (text: string, patterns: Compiled, vocabulary: RelativeVocabulary, number: NumberReader): Mention[] =>
196
+ [...text.matchAll(patterns.named)].flatMap((match) => {
197
+ const groups = match.groups ?? {};
198
+ const way = patterns.ways.get(groups["way"] ?? "");
199
+ const level = vocabulary.units[groups["unit"] ?? ""];
200
+ const count = countOf(groups["count"], vocabulary, number);
201
+ const suffix = suffixOf(groups["suffix"] ?? "", patterns, vocabulary, number);
202
+ if (way === undefined || level === undefined || count === undefined || suffix === undefined) return [];
203
+ return [{ start: match.index, end: match.index + match[0].length, attrs: { label: match[0], relative: way, level, count, suffix } }];
204
+ });
205
+
206
+ /** absolute は同じ行の番地を名指しした参照。条を書かない番地が、その続きかどうかを決めるのに使う。 */
207
+ export const relativeMentions = (
208
+ text: string,
209
+ profile: DocumentProfile | undefined,
210
+ number: NumberReader | undefined,
211
+ absolute: readonly Mention[] = [],
212
+ ): Mention[] => {
213
+ const vocabulary = profile?.relative;
214
+ if (profile === undefined || vocabulary === undefined || number === undefined) return [];
215
+ if (!compiled.has(profile)) compiled.set(profile, compile(profile, vocabulary));
216
+ const patterns = compiled.get(profile);
217
+ if (patterns === undefined) return [];
218
+ const substituted = substitutionSpans(text, vocabulary.substitution);
219
+ const named = namedMentions(text, patterns, vocabulary, number);
220
+ const bare = bareMentions(text, patterns, vocabulary, number, [...absolute, ...named]);
221
+ return [...named, ...bare]
222
+ .filter((mention) => !substituted.some((span) => span.start <= mention.start && mention.start < span.end))
223
+ .sort((left, right) => left.start - right.start);
224
+ };