chaffjs 0.5.0 → 0.6.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/dist/adapter-load.d.ts.map +1 -1
- package/dist/adapter-load.js +18 -2
- package/dist/adapter-load.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/cite.d.ts +16 -0
- package/dist/commands/cite.d.ts.map +1 -0
- package/dist/commands/cite.js +69 -0
- package/dist/commands/cite.js.map +1 -0
- package/dist/commands/tree.d.ts +33 -0
- package/dist/commands/tree.d.ts.map +1 -0
- package/dist/commands/tree.js +76 -0
- package/dist/commands/tree.js.map +1 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +4 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +6 -1
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/structure-tree.d.ts +5 -0
- package/dist/detectors/structure-tree.d.ts.map +1 -0
- package/dist/detectors/structure-tree.js +25 -0
- package/dist/detectors/structure-tree.js.map +1 -0
- package/dist/document.d.ts +9 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +21 -0
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts.map +1 -1
- package/dist/eval.js +13 -2
- package/dist/eval.js.map +1 -1
- package/dist/plugin.d.ts +71 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/position.d.ts.map +1 -1
- package/dist/position.js +21 -1
- package/dist/position.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +7 -2
- package/dist/run.js.map +1 -1
- package/dist/sentence-shape.d.ts.map +1 -1
- package/dist/sentence-shape.js +18 -1
- package/dist/sentence-shape.js.map +1 -1
- package/dist/structure/build.d.ts +20 -0
- package/dist/structure/build.d.ts.map +1 -0
- package/dist/structure/build.js +162 -0
- package/dist/structure/build.js.map +1 -0
- package/dist/structure/cite.d.ts +21 -0
- package/dist/structure/cite.d.ts.map +1 -0
- package/dist/structure/cite.js +91 -0
- package/dist/structure/cite.js.map +1 -0
- package/dist/structure/issues.d.ts +15 -0
- package/dist/structure/issues.d.ts.map +1 -0
- package/dist/structure/issues.js +53 -0
- package/dist/structure/issues.js.map +1 -0
- package/dist/structure/lines.d.ts +15 -0
- package/dist/structure/lines.d.ts.map +1 -0
- package/dist/structure/lines.js +33 -0
- package/dist/structure/lines.js.map +1 -0
- package/dist/structure/markdown-path.d.ts +3 -0
- package/dist/structure/markdown-path.d.ts.map +1 -0
- package/dist/structure/markdown-path.js +5 -0
- package/dist/structure/markdown-path.js.map +1 -0
- package/dist/structure/numbering.d.ts +9 -0
- package/dist/structure/numbering.d.ts.map +1 -0
- package/dist/structure/numbering.js +38 -0
- package/dist/structure/numbering.js.map +1 -0
- package/dist/structure/of.d.ts +13 -0
- package/dist/structure/of.d.ts.map +1 -0
- package/dist/structure/of.js +8 -0
- package/dist/structure/of.js.map +1 -0
- package/dist/structure/sexp.d.ts +7 -0
- package/dist/structure/sexp.d.ts.map +1 -0
- package/dist/structure/sexp.js +25 -0
- package/dist/structure/sexp.js.map +1 -0
- package/dist/structure/universal.d.ts +7 -0
- package/dist/structure/universal.d.ts.map +1 -0
- package/dist/structure/universal.js +21 -0
- package/dist/structure/universal.js.map +1 -0
- package/package.json +3 -3
- package/rules/concrete-evidence-density.yaml +2 -2
- package/rules/dangling-reference.yaml +27 -0
- package/rules/duplicate-definition.yaml +26 -0
- package/rules/numbering-gap.yaml +26 -0
- package/src/adapter-load.ts +19 -2
- package/src/cli.ts +6 -0
- package/src/commands/cite.ts +74 -0
- package/src/commands/tree.ts +94 -0
- package/src/detectors/index.ts +4 -0
- package/src/detectors/signals.ts +6 -1
- package/src/detectors/structure-tree.ts +32 -0
- package/src/document.ts +26 -2
- package/src/eval.ts +11 -2
- package/src/plugin.ts +75 -0
- package/src/position.ts +21 -1
- package/src/run.ts +7 -2
- package/src/sentence-shape.ts +19 -1
- package/src/structure/build.ts +203 -0
- package/src/structure/cite.ts +115 -0
- package/src/structure/issues.ts +63 -0
- package/src/structure/lines.ts +32 -0
- package/src/structure/markdown-path.ts +6 -0
- package/src/structure/numbering.ts +44 -0
- package/src/structure/of.ts +15 -0
- package/src/structure/sexp.ts +28 -0
- package/src/structure/universal.ts +21 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Heading } from "../document.ts";
|
|
2
|
+
import { maskSpans } from "../mask.ts";
|
|
3
|
+
import type { Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
|
|
4
|
+
import { lineNumberAt, linesOf, type Line } from "./lines.ts";
|
|
5
|
+
import { dottedNumber } from "./universal.ts";
|
|
6
|
+
|
|
7
|
+
/** 組み立て中の節点。できあがったら StructureNode に固める。 */
|
|
8
|
+
type Draft = {
|
|
9
|
+
readonly kind: StructureKind;
|
|
10
|
+
readonly address: string;
|
|
11
|
+
readonly start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
readonly line: number;
|
|
14
|
+
readonly attrs: Readonly<Record<string, string | number>>;
|
|
15
|
+
readonly children: Draft[];
|
|
16
|
+
readonly ordinal?: number | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** 開いている節点。rank の大きいものほど内側。 */
|
|
20
|
+
type Frame = { readonly draft: Draft; readonly rank: number; readonly numbered?: NumberedLine | undefined };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 見出しは深さ 1〜6 を rank にする。番号のまとまりは必ず見出しの内側に入るので、
|
|
24
|
+
* それより大きい rank から数える。「## 第3条」の下の番号だけの項は、見出しに閉じられずに条の中へ入る。
|
|
25
|
+
*/
|
|
26
|
+
const NUMBERED_RANK = 10;
|
|
27
|
+
|
|
28
|
+
type State = {
|
|
29
|
+
readonly stack: Frame[];
|
|
30
|
+
/** 見出しの深さごとの通し番号。番号の無い見出しの番地 h2.1 を作る。 */
|
|
31
|
+
readonly headingCounts: number[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const draftOf = (kind: StructureKind, address: string, line: Line, attrs: Readonly<Record<string, string | number>>): Draft => ({
|
|
35
|
+
kind,
|
|
36
|
+
address,
|
|
37
|
+
start: line.start,
|
|
38
|
+
end: line.start + line.text.length,
|
|
39
|
+
line: line.number,
|
|
40
|
+
attrs,
|
|
41
|
+
children: [],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const top = (state: State): Frame => {
|
|
45
|
+
const frame = state.stack.at(-1);
|
|
46
|
+
if (frame === undefined) throw new Error("the document frame was closed");
|
|
47
|
+
return frame;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** 自分と同じか内側の rank を閉じてから、親の子として開く。 */
|
|
51
|
+
const open = (state: State, frame: Frame): void => {
|
|
52
|
+
while (state.stack.length > 1 && top(state).rank >= frame.rank) state.stack.pop();
|
|
53
|
+
top(state).draft.children.push(frame.draft);
|
|
54
|
+
state.stack.push(frame);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const nearestNumbered = (state: State): Frame | undefined => [...state.stack].reverse().find((frame) => frame.numbered !== undefined);
|
|
58
|
+
|
|
59
|
+
const nearestSection = (state: State): Frame | undefined => [...state.stack].reverse().find((frame) => frame.draft.kind === "section");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 条番号や 4.2 はそれだけで番地になる。項・号・(a) は親の番地に続ける。親が無ければ節の中の番号とする。
|
|
63
|
+
* 呼ぶ前に自分と同じか内側の rank は閉じてあるので、残っている番号付きの節点が親。
|
|
64
|
+
*/
|
|
65
|
+
const addressOf = (state: State, numbered: NumberedLine): string => {
|
|
66
|
+
if (numbered.absolute) return numbered.number;
|
|
67
|
+
const parent = nearestNumbered(state);
|
|
68
|
+
if (parent !== undefined) return `${parent.draft.address}.${numbered.number}`;
|
|
69
|
+
const section = nearestSection(state);
|
|
70
|
+
return section === undefined ? numbered.number : `${section.draft.address}/${numbered.number}`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** 飛ばした深さは 0 と数える。「# A」の直後の「### C」は h1.0.1。番地に空の部品を作らない。 */
|
|
74
|
+
const headingAddress = (state: State, depth: number): string => {
|
|
75
|
+
state.headingCounts[depth - 1] = (state.headingCounts[depth - 1] ?? 0) + 1;
|
|
76
|
+
state.headingCounts.fill(0, depth);
|
|
77
|
+
const counts = Array.from({ length: depth }, (_, index) => state.headingCounts[index] ?? 0);
|
|
78
|
+
return `h${counts.map((count) => String(count)).join(".")}`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 見出しに書いた番号(「## 第3条」)は、見出しの深さで入れ子にする。本文の番号は見出しの内側に入る。
|
|
83
|
+
* 見出しの深さを捨てると、「## 第3条」の下の「### 詳細」が条の外に出てしまう。
|
|
84
|
+
*/
|
|
85
|
+
const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingDepth: number | undefined): void => {
|
|
86
|
+
const attrs = { label: numbered.label, ...(numbered.heading === "" ? {} : { heading: numbered.heading }) };
|
|
87
|
+
const rank = headingDepth ?? NUMBERED_RANK + numbered.depth;
|
|
88
|
+
// 番地は、自分と同じか内側を閉じてから決める。閉じる前だと、同じ深さの前の条を親と取り違える。
|
|
89
|
+
while (state.stack.length > 1 && top(state).rank >= rank) state.stack.pop();
|
|
90
|
+
const draft = { ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs), ordinal: numbered.ordinal };
|
|
91
|
+
open(state, { draft, rank, numbered });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const openSection = (state: State, line: Line, heading: Heading): void => {
|
|
95
|
+
while (state.stack.length > 1 && top(state).rank >= heading.depth) state.stack.pop();
|
|
96
|
+
open(state, { draft: draftOf("section", headingAddress(state, heading.depth), line, { heading: heading.text }), rank: heading.depth });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const LEAVES: readonly { readonly kind: StructureKind; readonly find: (patterns: StructurePatterns, text: string) => readonly Mention[] }[] = [
|
|
100
|
+
{ kind: "definition", find: (patterns, text) => patterns.definitions(text) },
|
|
101
|
+
{ kind: "reference", find: (patterns, text) => patterns.references(text) },
|
|
102
|
+
{ kind: "obligation", find: (patterns, text) => patterns.obligations(text) },
|
|
103
|
+
{ kind: "quantity", find: (patterns, text) => patterns.quantities(text) },
|
|
104
|
+
{ kind: "date", find: (patterns, text) => patterns.dates?.(text) ?? [] },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** 言語を問わない通し番号。後ろが単位なら数量なので番号にしない。 */
|
|
108
|
+
const universalNumber = (patterns: StructurePatterns, text: string, context: NumberingContext): NumberedLine | undefined => {
|
|
109
|
+
const dotted = dottedNumber(text, context);
|
|
110
|
+
return dotted !== undefined && patterns.countedAfter?.(dotted.number, dotted.rest) === true ? undefined : dotted;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** 行の中の定義・参照・義務・数量を、いま開いている最も内側の節点の子にする。 */
|
|
114
|
+
const addLeaves = (state: State, patterns: StructurePatterns, line: Line, text: string, offset: number): void => {
|
|
115
|
+
const parent = top(state).draft;
|
|
116
|
+
const leaves = LEAVES.flatMap(({ kind, find }) =>
|
|
117
|
+
find(patterns, text).map((mention) => ({ kind, start: line.start + offset + mention.start, end: line.start + offset + mention.end, attrs: mention.attrs })),
|
|
118
|
+
).sort((left, right) => left.start - right.start);
|
|
119
|
+
leaves.forEach((leaf) =>
|
|
120
|
+
parent.children.push({ kind: leaf.kind, address: "", start: leaf.start, end: leaf.end, line: line.number, attrs: leaf.attrs, children: [] }),
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const extend = (state: State, end: number): void => {
|
|
125
|
+
state.stack.forEach((frame) => {
|
|
126
|
+
frame.draft.end = Math.max(frame.draft.end, end);
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const freeze = (draft: Draft): StructureNode => ({
|
|
131
|
+
kind: draft.kind,
|
|
132
|
+
address: draft.address,
|
|
133
|
+
span: { start: draft.start, end: draft.end },
|
|
134
|
+
line: draft.line,
|
|
135
|
+
attrs: draft.attrs,
|
|
136
|
+
children: draft.children.map(freeze),
|
|
137
|
+
...(draft.ordinal === undefined ? {} : { ordinal: draft.ordinal }),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** Markdown から取った手がかり。見出しと、中を読まない範囲(コード)。.txt はどちらも空。 */
|
|
141
|
+
export type Outline = { readonly headings: readonly Heading[]; readonly opaque: readonly Span[] };
|
|
142
|
+
|
|
143
|
+
export const NO_OUTLINE: Outline = { headings: [], opaque: [] };
|
|
144
|
+
|
|
145
|
+
export type StructureInput = { readonly path: string; readonly source: string; readonly language: string; readonly outline: Outline };
|
|
146
|
+
|
|
147
|
+
/** 見出しのある行を、行番号から引けるようにする。見出しごとに全行を探し直さない。 */
|
|
148
|
+
const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): Map<number, Heading> =>
|
|
149
|
+
new Map(
|
|
150
|
+
headings.flatMap((heading) => {
|
|
151
|
+
const number = lineNumberAt(lines, heading.start);
|
|
152
|
+
if (number === undefined) return [];
|
|
153
|
+
const entry: [number, Heading] = [number, heading];
|
|
154
|
+
return [entry];
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
/** 見出しの行から「#」の印を外した文字列。コードを覆った後の行から取るので、見出しの中のコードも読まない。 */
|
|
159
|
+
const atxText = (text: string): string => {
|
|
160
|
+
const body = text
|
|
161
|
+
.trim()
|
|
162
|
+
.replace(/^#{1,6}(?=[ \t]|$)/u, "")
|
|
163
|
+
.trim();
|
|
164
|
+
const closing = body.search(/[ \t]#+$/u);
|
|
165
|
+
return (closing === -1 ? body : body.slice(0, closing)).trim();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const readLine = (state: State, patterns: StructurePatterns, line: Line, heading: Heading | undefined): void => {
|
|
169
|
+
const text = heading === undefined ? line.text : atxText(line.text);
|
|
170
|
+
const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
|
|
171
|
+
const context = { open: openNumbers, isHeading: heading !== undefined };
|
|
172
|
+
const numbered = patterns.numbered(text, context) ?? universalNumber(patterns, text, context);
|
|
173
|
+
// 番号付きの見出しも見出しの通し番号を進める。進めないと、その下の「### 詳細」が前の見出しの番地を名乗る。
|
|
174
|
+
if (numbered !== undefined && heading !== undefined) headingAddress(state, heading.depth);
|
|
175
|
+
if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth);
|
|
176
|
+
else if (heading !== undefined) openSection(state, line, heading);
|
|
177
|
+
extend(state, line.start + line.text.length);
|
|
178
|
+
// 番号付きの行は番号の後ろだけを読む。「第3条(支払)」の「第3条」を自分への参照として拾わない。
|
|
179
|
+
const scanned = numbered === undefined ? text : numbered.rest;
|
|
180
|
+
const offset = Math.max(0, line.text.lastIndexOf(scanned));
|
|
181
|
+
if (scanned !== "") addLeaves(state, patterns, line, scanned, offset);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 文書を番地の付いた木にする。番号の書き方は言語パッケージが読み、ここは入れ子と番地だけを決める。
|
|
186
|
+
* Markdown では見出しとコードの範囲を使い、.txt は行頭の番号だけで組む。
|
|
187
|
+
*/
|
|
188
|
+
export const buildTree = (input: StructureInput, patterns: StructurePatterns): StructureNode => {
|
|
189
|
+
const outline = input.outline;
|
|
190
|
+
// コードは同じ長さの空白で覆ってから読む。コードブロックの行は空になって飛ばされ、
|
|
191
|
+
// 文中の `第99条` は参照として拾われない。位置は元の文書のまま。
|
|
192
|
+
const lines = linesOf(maskSpans(input.source, outline.opaque));
|
|
193
|
+
const headings = headingsByLine(lines, outline.headings);
|
|
194
|
+
const doc = draftOf("doc", "", { text: "", start: 0, number: 1 }, { path: input.path, language: input.language });
|
|
195
|
+
doc.end = input.source.length;
|
|
196
|
+
const state: State = { stack: [{ draft: doc, rank: 0 }], headingCounts: [] };
|
|
197
|
+
lines.forEach((line) => {
|
|
198
|
+
if (line.text.trim() !== "") readLine(state, patterns, line, headings.get(line.number));
|
|
199
|
+
// コードの行は覆って読まないが、開いている節の中身ではある。節の最後にコードブロックがあっても、範囲をそこまで伸ばす。
|
|
200
|
+
else if (input.source.slice(line.start, line.start + line.text.length).trim() !== "") extend(state, line.start + line.text.length);
|
|
201
|
+
});
|
|
202
|
+
return freeze(doc);
|
|
203
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
import { lineStarts, placeOf } from "../position.ts";
|
|
3
|
+
|
|
4
|
+
// 回答の引用が原文にあるか。番地が木にあり、引用した文がその番地の範囲に書かれていれば一致。
|
|
5
|
+
// 意味が合っているかは見ない。書いてあるかどうかだけを、機械で決める。
|
|
6
|
+
|
|
7
|
+
export type Citation = { readonly address: string; readonly quote: string };
|
|
8
|
+
|
|
9
|
+
export type CitationStatus = "ok" | "missing-address" | "quote-elsewhere" | "quote-not-found";
|
|
10
|
+
|
|
11
|
+
export type CitationResult = {
|
|
12
|
+
readonly citation: Citation;
|
|
13
|
+
readonly status: CitationStatus;
|
|
14
|
+
/** 引用文が原文で見つかった位置の、いちばん内側の番地。quote-elsewhere のとき、本当はどこに書いてあるか。 */
|
|
15
|
+
readonly foundAt?: string;
|
|
16
|
+
/** 引用文が見つかった行。引用文が空なら、番地の行。 */
|
|
17
|
+
readonly line?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** 比べる形。空白と改行を除き、全角と半角を NFKC で揃える。index は各文字の元の位置。 */
|
|
21
|
+
type Normalized = { readonly text: string; readonly index: readonly number[] };
|
|
22
|
+
|
|
23
|
+
const WHITESPACE = /^\s+$/u;
|
|
24
|
+
const GRAPHEMES = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 書記素(見た目の一文字)ごとに NFKC をかける。1 符号単位ずつでは「ガ」と「ガ」、「ハ」+濁点と「バ」が揃わない。
|
|
28
|
+
* NFKC は 1 文字を複数にすることがある(「㈱」→「(株)」)。どれも元の書記素の先頭を指す。
|
|
29
|
+
*/
|
|
30
|
+
const normalize = (source: string): Normalized => {
|
|
31
|
+
const text: string[] = [];
|
|
32
|
+
const index: number[] = [];
|
|
33
|
+
[...GRAPHEMES.segment(source)].forEach(({ segment, index: at }) => {
|
|
34
|
+
if (WHITESPACE.test(segment)) return;
|
|
35
|
+
// 位置の表は UTF-16 の単位で持つ。検索(indexOf)がその単位で数えるので、絵文字のような 2 単位の文字でもずれない。
|
|
36
|
+
const normalized = segment.normalize("NFKC");
|
|
37
|
+
text.push(normalized);
|
|
38
|
+
index.push(...Array.from({ length: normalized.length }, () => at));
|
|
39
|
+
});
|
|
40
|
+
return { text: text.join(""), index };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Occurrence = { readonly start: number; readonly last: number };
|
|
44
|
+
|
|
45
|
+
const within = (node: StructureNode, occurrence: Occurrence): boolean => node.span.start <= occurrence.start && occurrence.last < node.span.end;
|
|
46
|
+
|
|
47
|
+
type Located = { readonly holding?: Occurrence; readonly earliest?: Occurrence };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* key が現れる位置を上から順に見て、候補の番地に収まるものが見つかった時点で止める。見つからなければ最初の位置を返す。
|
|
51
|
+
* 全部を配列に溜めない。「の」のような短い引用が長い原文に何百万回現れても、覚えるのは二つだけ。
|
|
52
|
+
* 次は 1 文字先から探す。重なって現れる引用(「ああ」の中の「ああ」)も、どの位置も見落とさない。
|
|
53
|
+
*/
|
|
54
|
+
const locate = (whole: Normalized, key: string, candidates: readonly StructureNode[]): Located => {
|
|
55
|
+
// 空の key は indexOf がどこまでも位置を返し、終わらなくなる。
|
|
56
|
+
if (key === "") return {};
|
|
57
|
+
let earliest: Occurrence | undefined;
|
|
58
|
+
for (let at = whole.text.indexOf(key); at !== -1; at = whole.text.indexOf(key, at + 1)) {
|
|
59
|
+
const occurrence = { start: whole.index[at] ?? 0, last: whole.index[at + key.length - 1] ?? 0 };
|
|
60
|
+
earliest ??= occurrence;
|
|
61
|
+
if (candidates.some((node) => within(node, occurrence))) return { holding: occurrence, earliest };
|
|
62
|
+
}
|
|
63
|
+
return earliest === undefined ? {} : { earliest };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type Addressed = { readonly node: StructureNode; readonly depth: number };
|
|
67
|
+
|
|
68
|
+
/** 番地を持つ節点と、その深さ。深いほど内側。 */
|
|
69
|
+
const addressedNodes = (tree: StructureNode): Addressed[] => {
|
|
70
|
+
const found: Addressed[] = [];
|
|
71
|
+
const pending: Addressed[] = [{ node: tree, depth: 0 }];
|
|
72
|
+
while (pending.length > 0) {
|
|
73
|
+
const current = pending.pop();
|
|
74
|
+
if (current === undefined) break;
|
|
75
|
+
if (current.node.address !== "") found.push(current);
|
|
76
|
+
[...current.node.children].reverse().forEach((child) => pending.push({ node: child, depth: current.depth + 1 }));
|
|
77
|
+
}
|
|
78
|
+
return found;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** 引用の全体を含む、いちばん内側の番地。二つの項にまたがる引用なら、両方を含む条。どの番地にも入らなければ undefined。 */
|
|
82
|
+
const innermostHolding = (nodes: readonly Addressed[], occurrence: Occurrence): string | undefined =>
|
|
83
|
+
nodes
|
|
84
|
+
.filter(({ node }) => within(node, occurrence))
|
|
85
|
+
.reduce<Addressed | undefined>((best, current) => (best === undefined || current.depth > best.depth ? current : best), undefined)?.node.address;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 引用を一件ずつ確かめる。source は木を作った原文そのもの。
|
|
89
|
+
* 引用文が空(空白だけ)なら番地だけを確かめる。同じ番地が二つある(附則が第1条から振り直す)ときは、どちらかにあれば一致。
|
|
90
|
+
* 原文は一度だけ揃え、引用ごとには番地の範囲を揃え直さない。長い契約書に引用が何百とあっても、引用ごとの代金は原文の長さに比例するだけ。
|
|
91
|
+
*/
|
|
92
|
+
export const checkCitations = (source: string, tree: StructureNode, citations: readonly Citation[]): CitationResult[] => {
|
|
93
|
+
const nodes = addressedNodes(tree);
|
|
94
|
+
const byAddress = new Map<string, StructureNode[]>();
|
|
95
|
+
nodes.forEach(({ node }) => {
|
|
96
|
+
const same = byAddress.get(node.address) ?? [];
|
|
97
|
+
same.push(node);
|
|
98
|
+
byAddress.set(node.address, same);
|
|
99
|
+
});
|
|
100
|
+
const whole = normalize(source);
|
|
101
|
+
const starts = lineStarts(source);
|
|
102
|
+
return citations.map((citation): CitationResult => {
|
|
103
|
+
const candidates = byAddress.get(citation.address) ?? [];
|
|
104
|
+
const first = candidates[0];
|
|
105
|
+
if (first === undefined) return { citation, status: "missing-address" };
|
|
106
|
+
const key = normalize(citation.quote).text;
|
|
107
|
+
if (key === "") return { citation, status: "ok", line: first.line };
|
|
108
|
+
const { holding, earliest } = locate(whole, key, candidates);
|
|
109
|
+
if (holding !== undefined) return { citation, status: "ok", line: placeOf(starts, holding.start).line };
|
|
110
|
+
if (earliest === undefined) return { citation, status: "quote-not-found" };
|
|
111
|
+
const foundAt = innermostHolding(nodes, earliest);
|
|
112
|
+
const line = placeOf(starts, earliest.start).line;
|
|
113
|
+
return { citation, status: "quote-elsewhere", line, ...(foundAt === undefined ? {} : { foundAt }) };
|
|
114
|
+
});
|
|
115
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
// 木だけで決まる誤り。参照先が無い、番号が飛ぶ・重なる、同じ語を二度定義する。
|
|
4
|
+
// 意味の食い違いは判定しない。書いてあることから機械で決まるものだけを出す。
|
|
5
|
+
|
|
6
|
+
export type StructureIssue = { readonly offset: number; readonly values: Readonly<Record<string, string | number>> };
|
|
7
|
+
|
|
8
|
+
const NUMBERED: readonly string[] = ["chapter", "article", "item"];
|
|
9
|
+
|
|
10
|
+
/** 木を上から順に平らにする。文書の中での順番と同じになる。再帰にしないのは、深い木でスタックを使い切らないため。 */
|
|
11
|
+
export const inDocumentOrder = (tree: StructureNode): StructureNode[] => {
|
|
12
|
+
const order: StructureNode[] = [];
|
|
13
|
+
const pending: StructureNode[] = [tree];
|
|
14
|
+
while (pending.length > 0) {
|
|
15
|
+
const node = pending.pop();
|
|
16
|
+
if (node === undefined) break;
|
|
17
|
+
order.push(node);
|
|
18
|
+
pending.push(...[...node.children].reverse());
|
|
19
|
+
}
|
|
20
|
+
return order;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const textOf = (node: StructureNode, key: string): string => {
|
|
24
|
+
const value = node.attrs[key];
|
|
25
|
+
return value === undefined ? "" : String(value);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 参照がこの文書のどこかを指しているか。fallback は言語パッケージが付ける別の行き先で、
|
|
30
|
+
* 日本語の「第4条第1項」は、番号の無い第 1 項を持つ第4条を指しうる。
|
|
31
|
+
*/
|
|
32
|
+
const resolves = (node: StructureNode, addresses: ReadonlySet<string>): boolean =>
|
|
33
|
+
addresses.has(textOf(node, "target")) || (node.attrs["fallback"] !== undefined && addresses.has(textOf(node, "fallback")));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 参照の番地が木に無い。他の文書の名前が付いた参照(民法第709条、Section 9 of the Master Agreement)は引かない。
|
|
37
|
+
* 番号付きのまとまりを 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
|
|
38
|
+
*/
|
|
39
|
+
export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
|
|
40
|
+
const nodes = inDocumentOrder(tree);
|
|
41
|
+
const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
|
|
42
|
+
if (addresses.size === 0) return [];
|
|
43
|
+
return nodes
|
|
44
|
+
.filter((node) => node.kind === "reference" && node.attrs["document"] === undefined)
|
|
45
|
+
.filter((node) => !resolves(node, addresses))
|
|
46
|
+
.map((node) => ({ offset: node.span.start, values: { label: textOf(node, "label"), target: textOf(node, "target") } }));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。 */
|
|
50
|
+
export const duplicateDefinitions = (tree: StructureNode): StructureIssue[] => {
|
|
51
|
+
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
|
+
});
|
|
63
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** 1 行。text は改行を含まず、\r\n の \r も落としてある。start は元の文書での位置。 */
|
|
2
|
+
export type Line = { readonly text: string; readonly start: number; readonly number: number };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 行に分ける。位置は元の文字列のまま数える。
|
|
6
|
+
* Windows で書かれた契約書は \r\n で届くので、\r を本文に残すと行末の判定がずれる。
|
|
7
|
+
* 行ごとに配列を作り直さない。何万行もある契約書で二乗に遅くなるため。
|
|
8
|
+
*/
|
|
9
|
+
export const linesOf = (source: string): Line[] => {
|
|
10
|
+
const lines: Line[] = [];
|
|
11
|
+
let at = 0;
|
|
12
|
+
source.split("\n").forEach((raw, index) => {
|
|
13
|
+
lines.push({ text: raw.endsWith("\r") ? raw.slice(0, -1) : raw, start: at, number: index + 1 });
|
|
14
|
+
at += raw.length + 1;
|
|
15
|
+
});
|
|
16
|
+
return lines;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** offset を含む行の番号(1 始まり)。lines は start の昇順。二分探索で、行数に対して対数で引く。 */
|
|
20
|
+
export const lineNumberAt = (lines: readonly Line[], offset: number): number | undefined => {
|
|
21
|
+
let low = 0;
|
|
22
|
+
let high = lines.length - 1;
|
|
23
|
+
while (low <= high) {
|
|
24
|
+
const middle = (low + high) >> 1;
|
|
25
|
+
const line = lines[middle];
|
|
26
|
+
if (line === undefined) return undefined;
|
|
27
|
+
if (offset < line.start) high = middle - 1;
|
|
28
|
+
else if (offset > line.start + line.text.length) low = middle + 1;
|
|
29
|
+
else return line.number;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
import { inDocumentOrder, type StructureIssue } from "./issues.ts";
|
|
3
|
+
|
|
4
|
+
const labelOf = (node: StructureNode): string => String(node.attrs["label"] ?? node.address);
|
|
5
|
+
|
|
6
|
+
type Ordered = { readonly node: StructureNode; readonly ordinal: number };
|
|
7
|
+
|
|
8
|
+
/** 同じ親の子のうち、番号の付いたものを種類ごとに分ける。条と項は別の並び。 */
|
|
9
|
+
const sequencesOf = (parent: StructureNode): Ordered[][] => {
|
|
10
|
+
const groups = new Map<string, Ordered[]>();
|
|
11
|
+
parent.children.forEach((node) => {
|
|
12
|
+
if (node.ordinal === undefined) return;
|
|
13
|
+
// 配列を作り直さずに足す。条が何千もある法令で二乗にしない。
|
|
14
|
+
const group = groups.get(node.kind) ?? [];
|
|
15
|
+
group.push({ node, ordinal: node.ordinal });
|
|
16
|
+
groups.set(node.kind, group);
|
|
17
|
+
});
|
|
18
|
+
return [...groups.values()];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** 隣り合う二つの番号が、1 つ進んでいなければ指摘する。同じ番号なら重なり、飛べば抜け。 */
|
|
22
|
+
const breaksIn = (sequence: readonly Ordered[]): StructureIssue[] =>
|
|
23
|
+
sequence.flatMap((current, index) => {
|
|
24
|
+
const previous = sequence[index - 1];
|
|
25
|
+
// 1 に戻ったら新しい並び。一つの条に (a)(b) の箇条書きが二つある、附則が第1条から振り直す、のどちらも誤りではない。
|
|
26
|
+
if (previous === undefined || current.ordinal === previous.ordinal + 1 || current.ordinal === 1) return [];
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
offset: current.node.span.start,
|
|
30
|
+
values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.ordinal + 1, found: current.ordinal },
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 番号の抜けと重なり。比べるのは同じ親の子どうしだけ。英米の法令は章ごとに Section 101、201 と百の位を変えるので、
|
|
37
|
+
* 文書全体の通し番号として比べると抜けに見える。最初の番号は見ない。日本語の項は 2 から番号を振る。
|
|
38
|
+
* 1 に戻った番号は新しい並びの始まりとして扱うので、「(a) の後の (a)」の重なりは見逃す。
|
|
39
|
+
*/
|
|
40
|
+
export const numberingBreaks = (tree: StructureNode): StructureIssue[] =>
|
|
41
|
+
inDocumentOrder(tree)
|
|
42
|
+
.flatMap(sequencesOf)
|
|
43
|
+
.flatMap(breaksIn)
|
|
44
|
+
.sort((left, right) => left.offset - right.offset);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { markdownOutline } from "../document.ts";
|
|
2
|
+
import type { StructureNode, StructurePatterns } from "../plugin.ts";
|
|
3
|
+
import { buildTree, NO_OUTLINE } from "./build.ts";
|
|
4
|
+
|
|
5
|
+
export type SourceInput = { readonly path: string; readonly source: string; readonly language: string; readonly markdown: boolean };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 文字列から木を作る。Markdown なら見出しとコードの範囲を先に取る。
|
|
9
|
+
* lint は文書モデルを作るときの解析を使い回すので、ここを通らずに buildTree を呼ぶ。
|
|
10
|
+
*/
|
|
11
|
+
export const buildStructure = (input: SourceInput, patterns: StructurePatterns): StructureNode =>
|
|
12
|
+
buildTree(
|
|
13
|
+
{ path: input.path, source: input.source, language: input.language, outline: input.markdown ? markdownOutline(input.source) : NO_OUTLINE },
|
|
14
|
+
patterns,
|
|
15
|
+
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { StructureNode } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/** 文字列は二重引用符で囲み、中の \ と " だけを逃がす。改行は \n にして 1 行に保つ。 */
|
|
4
|
+
const quote = (text: string): string => `"${text.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\r?\n/gu, "\\n")}"`;
|
|
5
|
+
|
|
6
|
+
const atom = (value: string | number): string => (typeof value === "number" ? String(value) : quote(value));
|
|
7
|
+
|
|
8
|
+
/** 属性はキーの順に並べる。同じ木から毎回同じ文字列を出すため。 */
|
|
9
|
+
const keywords = (node: StructureNode): string[] => [
|
|
10
|
+
...Object.keys(node.attrs)
|
|
11
|
+
.sort((left, right) => left.localeCompare(right, "en"))
|
|
12
|
+
.flatMap((key) => {
|
|
13
|
+
const value = node.attrs[key];
|
|
14
|
+
return value === undefined ? [] : [`:${key} ${atom(value)}`];
|
|
15
|
+
}),
|
|
16
|
+
`:line ${String(node.line)}`,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 木を S 式にする。人と AI が読むための形で、JSON と同じ木から作る。
|
|
21
|
+
* `(kind "番地" :key value ... 子)`。番地を持たない葉は番地を書かない。
|
|
22
|
+
*/
|
|
23
|
+
export const toSexp = (node: StructureNode, indent = ""): string => {
|
|
24
|
+
const head = [node.kind, ...(node.address === "" ? [] : [quote(node.address)]), ...keywords(node)].join(" ");
|
|
25
|
+
if (node.children.length === 0) return `${indent}(${head})`;
|
|
26
|
+
const inner = node.children.map((child) => toSexp(child, `${indent} `)).join("\n");
|
|
27
|
+
return `${indent}(${head}\n${inner})`;
|
|
28
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NumberedLine, NumberingContext } from "../plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「4.2 設定」「3.1.4 Scope」のような通し番号。数字と点だけで書くので言語を問わない。
|
|
5
|
+
* 各部分を 3 桁・深さを 6 までに抑え、後戻りが爆発しない形にしてある。
|
|
6
|
+
*/
|
|
7
|
+
const DOTTED = /^[ \t]{0,3}(?<number>\d{1,3}(?:\.\d{1,3}){0,5})\.?[ \t\u3000]+(?<rest>\S.*)$/u;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。点を含まない番号は、見出しの行でだけ読む。
|
|
11
|
+
* 「4.2 」のように点を含むものは、.txt の仕様書でも見出しとして書かれるので本文でも読む。
|
|
12
|
+
*/
|
|
13
|
+
export const dottedNumber = (line: string, context: NumberingContext): NumberedLine | undefined => {
|
|
14
|
+
const groups = DOTTED.exec(line)?.groups;
|
|
15
|
+
const number = groups?.["number"];
|
|
16
|
+
if (number === undefined) return undefined;
|
|
17
|
+
const depth = number.split(".").length;
|
|
18
|
+
if (depth < 2 && !context.isHeading) return undefined;
|
|
19
|
+
const rest = groups?.["rest"]?.trim() ?? "";
|
|
20
|
+
return { kind: "article", depth, number, absolute: true, label: number, heading: rest, rest, ordinal: Number(number.split(".").at(-1)) };
|
|
21
|
+
};
|