@hajimism/dek 0.5.1 → 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/CHANGELOG.md +18 -0
- package/package.json +10 -3
- package/src/cli/build.ts +3 -1
- package/src/cli/commands.ts +24 -10
- package/src/cli/contract.ts +28 -2
- package/src/cli/flags.ts +5 -0
- package/src/cli/lint.ts +53 -1
- package/src/cli/sync.ts +21 -6
- package/src/cli/text.ts +20 -2
- package/src/core/build.ts +4 -0
- package/src/core/config-keys.ts +7 -2
- package/src/core/config.ts +63 -0
- package/src/core/diagnostic.ts +9 -1
- package/src/core/document.ts +28 -3
- package/src/core/page.ts +1 -0
- package/src/core/plugin-host.ts +669 -0
- package/src/core/plugin.ts +436 -0
- package/src/core/sarif.ts +47 -5
- package/src/core/sync.ts +3 -1
- package/src/plugin-api.ts +126 -0
- package/src/plugins/study/check.ts +128 -0
- package/src/plugins/study/main.ts +47 -0
- package/src/plugins/study/manifest.ts +32 -0
- package/src/plugins/study/page.ts +295 -0
- package/src/plugins/study/place.ts +88 -0
- package/src/plugins/study/sarif.ts +30 -0
- package/src/plugins/study/study.ts +137 -0
- package/src/plugins/study/text.ts +55 -0
- package/src/plugins/study/yaml.ts +226 -0
- package/src/plugins/words/check.ts +109 -0
- package/src/plugins/words/lines.ts +26 -0
- package/src/plugins/words/list.ts +288 -0
- package/src/plugins/words/main.ts +50 -0
- package/src/plugins/words/manifest.ts +36 -0
- package/src/plugins/words/match.ts +60 -0
- package/src/plugins/words/sarif.ts +37 -0
- package/src/plugins/words/text.ts +352 -0
- package/src/plugins/words/yaml.ts +225 -0
- package/src/runtime/player.ts +18 -2
- package/src/runtime/plugins.ts +236 -0
- package/src/runtime/start.ts +16 -1
- package/src/runtime/window.ts +3 -0
- package/src/server/deck-snapshot.ts +32 -1
- package/src/server/dev.ts +7 -1
- package/src/server/pages.ts +9 -1
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { ALLOW_FILE, LIST_PATH } from "./manifest.ts";
|
|
2
|
+
import { parseYaml, YamlError } from "./yaml.ts";
|
|
3
|
+
|
|
4
|
+
/** How a verb's forms are found: by its stem and each kana of its row, or by its stem alone. */
|
|
5
|
+
type Verb = "godan" | "ichidan";
|
|
6
|
+
|
|
7
|
+
/** One word on the project's list. */
|
|
8
|
+
export type Entry = {
|
|
9
|
+
word: string;
|
|
10
|
+
/** A regular expression looked for instead of `word`. */
|
|
11
|
+
pattern?: string;
|
|
12
|
+
verb?: Verb;
|
|
13
|
+
/** Longer words that hold the word and are not it. */
|
|
14
|
+
except: string[];
|
|
15
|
+
/** What to write instead. */
|
|
16
|
+
use?: string;
|
|
17
|
+
/** Where to look, when only one of them. */
|
|
18
|
+
in?: "slides" | "script";
|
|
19
|
+
/** Where the entry starts in the list. */
|
|
20
|
+
line: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** What is wrong with a file, and the line to look at. */
|
|
24
|
+
export type Problem = { message: string; line: number };
|
|
25
|
+
|
|
26
|
+
const VERSION = 1;
|
|
27
|
+
const ENTRY_KEYS = new Set(["word", "pattern", "verb", "except", "use", "in"]);
|
|
28
|
+
|
|
29
|
+
/** The last kana of a godan verb's dictionary form. */
|
|
30
|
+
const GODAN_ENDINGS = ["う", "く", "ぐ", "す", "つ", "ぬ", "ぶ", "む", "る"];
|
|
31
|
+
|
|
32
|
+
/** What YAML made of words written without quotes, when it made them something other than text. */
|
|
33
|
+
function unquoted(value: unknown): string | undefined {
|
|
34
|
+
if (typeof value === "number") {
|
|
35
|
+
return `the number ${value}`;
|
|
36
|
+
}
|
|
37
|
+
return typeof value === "boolean" ? String(value) : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Read = {
|
|
41
|
+
/** The top mapping's fields, or why the file gives none. */
|
|
42
|
+
fields?: Record<string, unknown>;
|
|
43
|
+
problem?: Problem;
|
|
44
|
+
lineOf: (mapping: object) => number | undefined;
|
|
45
|
+
/** The line of the first line matching `pattern`, or of the top. */
|
|
46
|
+
lineWith: (pattern: RegExp) => number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A file of this plugin, read as far as its version: a mapping of version 1, or a problem. */
|
|
50
|
+
function readFile(source: string, name: string, keys: string[]): Read {
|
|
51
|
+
const lines = source.split(/\r?\n/);
|
|
52
|
+
const lineWith = (pattern: RegExp) => {
|
|
53
|
+
const index = lines.findIndex((text) => pattern.test(text));
|
|
54
|
+
return index === -1 ? 1 : index + 1;
|
|
55
|
+
};
|
|
56
|
+
let parsed: ReturnType<typeof parseYaml>;
|
|
57
|
+
try {
|
|
58
|
+
parsed = parseYaml(source);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof YamlError) {
|
|
61
|
+
const problem = {
|
|
62
|
+
message: `${name} is not YAML dek-words reads: ${error.message}`,
|
|
63
|
+
line: error.line,
|
|
64
|
+
};
|
|
65
|
+
return { problem, lineOf: () => undefined, lineWith };
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
const { value, lineOf } = parsed;
|
|
70
|
+
if (value === null) {
|
|
71
|
+
return { lineOf, lineWith };
|
|
72
|
+
}
|
|
73
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
74
|
+
return { problem: { message: `${name} is not a mapping`, line: 1 }, lineOf, lineWith };
|
|
75
|
+
}
|
|
76
|
+
const fields = value as Record<string, unknown>;
|
|
77
|
+
if (fields.version !== VERSION) {
|
|
78
|
+
const message = `${name} is version ${String(fields.version)}; this dek-words reads version ${VERSION}`;
|
|
79
|
+
return { problem: { message, line: lineWith(/^version\s*:/) }, lineOf, lineWith };
|
|
80
|
+
}
|
|
81
|
+
const extra = Object.keys(fields).find((key) => key !== "version" && !keys.includes(key));
|
|
82
|
+
if (extra !== undefined) {
|
|
83
|
+
const hint = keys.length === 1 ? `; write \`${keys[0]}\`` : "";
|
|
84
|
+
const message = `${name} has \`${extra}\`, which it does not take${hint}`;
|
|
85
|
+
return { problem: { message, line: lineWith(new RegExp(`^${extra}\\s*:`)) }, lineOf, lineWith };
|
|
86
|
+
}
|
|
87
|
+
return { fields, lineOf, lineWith };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The entries of the project's list, and what keeps the rest from being used. An entry with a
|
|
92
|
+
* problem is left out, so every entry that reads is still checked.
|
|
93
|
+
*/
|
|
94
|
+
export function readList(source: string): { entries: Entry[]; problems: Problem[] } {
|
|
95
|
+
const file = readFile(source, LIST_PATH, ["words"]);
|
|
96
|
+
if (file.problem) {
|
|
97
|
+
return { entries: [], problems: [file.problem] };
|
|
98
|
+
}
|
|
99
|
+
const words = file.fields?.words;
|
|
100
|
+
if (words === undefined || words === null) {
|
|
101
|
+
return { entries: [], problems: [] };
|
|
102
|
+
}
|
|
103
|
+
if (!Array.isArray(words)) {
|
|
104
|
+
return {
|
|
105
|
+
entries: [],
|
|
106
|
+
problems: [{ message: "`words` is not a list", line: file.lineWith(/^words\s*:/) }],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const entries: Entry[] = [];
|
|
110
|
+
const problems: Problem[] = [];
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
words.forEach((item: unknown, index) => {
|
|
113
|
+
const fields =
|
|
114
|
+
typeof item === "object" && item !== null && !Array.isArray(item)
|
|
115
|
+
? (item as Record<string, unknown>)
|
|
116
|
+
: undefined;
|
|
117
|
+
const line = (fields && file.lineOf(fields)) ?? file.lineWith(/^words\s*:/);
|
|
118
|
+
const read = readEntry(fields, `#${index + 1}`, line);
|
|
119
|
+
if ("problem" in read) {
|
|
120
|
+
problems.push({ message: read.problem, line });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (seen.has(read.entry.word)) {
|
|
124
|
+
problems.push({ message: `entry ${read.entry.word} is on the list twice; keep one`, line });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
seen.add(read.entry.word);
|
|
128
|
+
entries.push(read.entry);
|
|
129
|
+
});
|
|
130
|
+
return { entries, problems };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every `word` the list names, entries that do not read included, so an allow of one is no mistake. */
|
|
134
|
+
export function listedWords(source: string): Set<string> {
|
|
135
|
+
const { fields } = readFile(source, LIST_PATH, ["words"]);
|
|
136
|
+
const words = Array.isArray(fields?.words) ? fields.words : [];
|
|
137
|
+
return new Set(
|
|
138
|
+
words.flatMap((item: unknown) => {
|
|
139
|
+
const word = (item as { word?: unknown } | null)?.word;
|
|
140
|
+
return typeof word === "string" && word !== "" ? [word] : [];
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readEntry(
|
|
146
|
+
fields: Record<string, unknown> | undefined,
|
|
147
|
+
position: string,
|
|
148
|
+
line: number,
|
|
149
|
+
): { entry: Entry } | { problem: string } {
|
|
150
|
+
if (fields === undefined) {
|
|
151
|
+
return { problem: `entry ${position} is not a mapping of \`word\` and the rest` };
|
|
152
|
+
}
|
|
153
|
+
const word = typeof fields.word === "string" && fields.word !== "" ? fields.word : undefined;
|
|
154
|
+
const name = word ?? position;
|
|
155
|
+
for (const key of ["word", "pattern", "use"] as const) {
|
|
156
|
+
const made = unquoted(fields[key]);
|
|
157
|
+
if (made !== undefined) {
|
|
158
|
+
return { problem: `entry ${name}: YAML reads its \`${key}\` as ${made}; put it in quotes` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (word === undefined) {
|
|
162
|
+
return { problem: `entry ${name} has no \`word\`` };
|
|
163
|
+
}
|
|
164
|
+
const extra = Object.keys(fields).find((key) => !ENTRY_KEYS.has(key));
|
|
165
|
+
if (extra !== undefined) {
|
|
166
|
+
return { problem: `entry ${word} has \`${extra}\`, which the list does not take` };
|
|
167
|
+
}
|
|
168
|
+
const { pattern, verb, except = [], use, in: where } = fields;
|
|
169
|
+
if (pattern !== undefined && verb !== undefined) {
|
|
170
|
+
return { problem: `entry ${word} has both \`verb\` and \`pattern\`; keep one` };
|
|
171
|
+
}
|
|
172
|
+
if (pattern !== undefined) {
|
|
173
|
+
const problem = patternProblem(pattern);
|
|
174
|
+
if (problem) {
|
|
175
|
+
return { problem: `entry ${word}: ${problem}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (verb !== undefined) {
|
|
179
|
+
const problem = verbProblem(word, verb);
|
|
180
|
+
if (problem) {
|
|
181
|
+
return { problem: `entry ${word}: ${problem}` };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(except) || except.some((item) => typeof item !== "string" || item === "")) {
|
|
185
|
+
return { problem: `entry ${word}: \`except\` is a list of words` };
|
|
186
|
+
}
|
|
187
|
+
if (use !== undefined && typeof use !== "string") {
|
|
188
|
+
return { problem: `entry ${word}: \`use\` is text` };
|
|
189
|
+
}
|
|
190
|
+
if (where !== undefined && where !== "slides" && where !== "script") {
|
|
191
|
+
return { problem: `entry ${word}: \`in\` is slides or script, not ${String(where)}` };
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
entry: {
|
|
195
|
+
word,
|
|
196
|
+
...(typeof pattern === "string" ? { pattern } : {}),
|
|
197
|
+
...(verb === "godan" || verb === "ichidan" ? { verb } : {}),
|
|
198
|
+
except: except as string[],
|
|
199
|
+
...(typeof use === "string" && use !== "" ? { use } : {}),
|
|
200
|
+
...(where === "slides" || where === "script" ? { in: where } : {}),
|
|
201
|
+
line,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function patternProblem(pattern: unknown): string | undefined {
|
|
207
|
+
if (typeof pattern !== "string" || pattern === "") {
|
|
208
|
+
return "`pattern` is a regular expression, written as text";
|
|
209
|
+
}
|
|
210
|
+
let compiled: RegExp;
|
|
211
|
+
try {
|
|
212
|
+
compiled = new RegExp(pattern, "iu");
|
|
213
|
+
} catch {
|
|
214
|
+
return `\`pattern\` is not a regular expression: ${pattern}`;
|
|
215
|
+
}
|
|
216
|
+
return compiled.test("") ? "`pattern` matches where there is no text" : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function verbProblem(word: string, verb: unknown): string | undefined {
|
|
220
|
+
if (verb !== "godan" && verb !== "ichidan") {
|
|
221
|
+
return `\`verb\` is godan or ichidan, not ${String(verb)}`;
|
|
222
|
+
}
|
|
223
|
+
const letters = [...word];
|
|
224
|
+
const last = letters.at(-1) ?? "";
|
|
225
|
+
if (verb === "godan" && (letters.length < 2 || !GODAN_ENDINGS.includes(last))) {
|
|
226
|
+
return `a godan verb ends in ${GODAN_ENDINGS.slice(0, -1).join(", ")}, or る`;
|
|
227
|
+
}
|
|
228
|
+
if (verb === "ichidan" && (letters.length < 2 || last !== "る")) {
|
|
229
|
+
return "an ichidan verb ends in る";
|
|
230
|
+
}
|
|
231
|
+
// An ichidan verb is found by its stem alone, and 見 alone is in 意見 and 見解.
|
|
232
|
+
if (verb === "ichidan" && letters.length === 2) {
|
|
233
|
+
return `its stem ${letters[0]} is one letter, which too many words hold; write a \`pattern\` for its forms`;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The words a deck allows, each an entry's `word`, and what is wrong with the file. */
|
|
239
|
+
export function readAllows(
|
|
240
|
+
source: string,
|
|
241
|
+
listed: Set<string>,
|
|
242
|
+
): { allows: Set<string>; problems: Problem[] } {
|
|
243
|
+
const file = readFile(source, ALLOW_FILE, ["allow"]);
|
|
244
|
+
if (file.problem) {
|
|
245
|
+
return { allows: new Set(), problems: [file.problem] };
|
|
246
|
+
}
|
|
247
|
+
const allow = file.fields?.allow;
|
|
248
|
+
if (allow === undefined || allow === null) {
|
|
249
|
+
return { allows: new Set(), problems: [] };
|
|
250
|
+
}
|
|
251
|
+
const listLine = file.lineWith(/^allow\s*:/);
|
|
252
|
+
if (!Array.isArray(allow)) {
|
|
253
|
+
return {
|
|
254
|
+
allows: new Set(),
|
|
255
|
+
problems: [{ message: "`allow` is a list of words", line: listLine }],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const allows = new Set<string>();
|
|
259
|
+
const problems: Problem[] = [];
|
|
260
|
+
const lines = source.split(/\r?\n/);
|
|
261
|
+
for (const item of allow) {
|
|
262
|
+
const word = typeof item === "string" ? item : String(item);
|
|
263
|
+
const index = lines.findIndex(
|
|
264
|
+
(text, at) =>
|
|
265
|
+
at >= listLine && new RegExp(`^\\s*-\\s*['"]?${literal(word)}['"]?\\s*(#.*)?$`).test(text),
|
|
266
|
+
);
|
|
267
|
+
const line = index === -1 ? listLine : index + 1;
|
|
268
|
+
if (typeof item !== "string") {
|
|
269
|
+
problems.push({
|
|
270
|
+
message: `${ALLOW_FILE}: YAML reads an allow as ${unquoted(item) ?? "no text"}; put it in quotes`,
|
|
271
|
+
line,
|
|
272
|
+
});
|
|
273
|
+
} else if (!listed.has(item)) {
|
|
274
|
+
problems.push({
|
|
275
|
+
message: `${ALLOW_FILE} allows ${item}, which is no entry on ${LIST_PATH}`,
|
|
276
|
+
line,
|
|
277
|
+
});
|
|
278
|
+
} else {
|
|
279
|
+
allows.add(item);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { allows, problems };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `text` as a regular expression that matches it as written. */
|
|
286
|
+
export function literal(text: string): string {
|
|
287
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
288
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* dek-words, the second plugin dek ships (ADR 36). dek runs it as any plugin (ADR 32), in a bun of its
|
|
4
|
+
* own: `manifest` to pin what it promises, and `check --protocol 1` in a deck's folder, with the
|
|
5
|
+
* deck on stdin, for SARIF. It shares only the types in plugin-api.ts with dek, never its code.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import type { CheckInput } from "../../plugin-api.ts";
|
|
9
|
+
import { check } from "./check.ts";
|
|
10
|
+
import { MANIFEST } from "./manifest.ts";
|
|
11
|
+
import { toSarif } from "./sarif.ts";
|
|
12
|
+
|
|
13
|
+
const USAGE = "usage: dek-words manifest | dek-words check --protocol 1 (dek runs both)";
|
|
14
|
+
|
|
15
|
+
async function main(args: string[]): Promise<number> {
|
|
16
|
+
switch (args[0]) {
|
|
17
|
+
case "manifest":
|
|
18
|
+
process.stdout.write(`${JSON.stringify(MANIFEST)}\n`);
|
|
19
|
+
return 0;
|
|
20
|
+
case "check": {
|
|
21
|
+
if (args[1] !== "--protocol" || args[2] !== "1") {
|
|
22
|
+
process.stderr.write(
|
|
23
|
+
`dek-words speaks check protocol 1; dek asked for ${args.slice(1).join(" ")}\n`,
|
|
24
|
+
);
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
const input = JSON.parse(await Bun.stdin.text()) as CheckInput;
|
|
28
|
+
const findings = await check(input, async (path) => readIfThere(path));
|
|
29
|
+
process.stdout.write(`${JSON.stringify(toSarif(findings))}\n`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
default:
|
|
33
|
+
process.stderr.write(`${USAGE}\n`);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A file of the deck, relative to its folder, which is where dek runs the check, or the project's
|
|
40
|
+
* list by its absolute path.
|
|
41
|
+
*/
|
|
42
|
+
function readIfThere(path: string): string | undefined {
|
|
43
|
+
try {
|
|
44
|
+
return readFileSync(path, "utf8");
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
process.exit(await main(process.argv.slice(2)));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PluginManifest, PluginRules } from "../../plugin-api.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one place each rule is declared: the manifest prints it, and a finding can name only a rule
|
|
5
|
+
* here, so dek-words cannot report an id its manifest does not declare.
|
|
6
|
+
*/
|
|
7
|
+
const RULES = {
|
|
8
|
+
WORDS001: { severity: "warning", summary: "a word on the project's word list is in the deck" },
|
|
9
|
+
WORDS002: { severity: "error", summary: "the word list or a deck's allows cannot be read" },
|
|
10
|
+
} as const satisfies PluginRules<"WORDS">;
|
|
11
|
+
|
|
12
|
+
export type RuleId = keyof typeof RULES;
|
|
13
|
+
|
|
14
|
+
/** The project's list, in `plugins/words/` (ADR 35). */
|
|
15
|
+
export const LIST_FILE = "words.yaml";
|
|
16
|
+
|
|
17
|
+
/** The list as an agent finds it, from the project's root. */
|
|
18
|
+
export const LIST_PATH = `plugins/words/${LIST_FILE}`;
|
|
19
|
+
|
|
20
|
+
/** The words a deck allows, in its folder. */
|
|
21
|
+
export const ALLOW_FILE = "words.yaml";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* dek-words' own version, apart from dek's: a project accepts a change to what it promises, and
|
|
25
|
+
* a dek release that leaves the rules as they were must not ask for that.
|
|
26
|
+
*/
|
|
27
|
+
const VERSION = "0.1.0";
|
|
28
|
+
|
|
29
|
+
/** What `dek-words manifest` prints: the same every time, since dek pins it. */
|
|
30
|
+
export const MANIFEST = {
|
|
31
|
+
schema: 1,
|
|
32
|
+
name: "words",
|
|
33
|
+
version: VERSION,
|
|
34
|
+
check: { protocol: 1, rules: RULES },
|
|
35
|
+
data: [{ path: LIST_FILE, in: "project" }, { path: ALLOW_FILE }],
|
|
36
|
+
} as const satisfies PluginManifest<"WORDS">;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Entry, literal } from "./list.ts";
|
|
2
|
+
|
|
3
|
+
/** One place an entry is found: where it starts in the block's text, and the text found. */
|
|
4
|
+
export type Found = { index: number; text: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The kana after a godan verb's stem, by the kana its dictionary form ends in: the five of its
|
|
8
|
+
* row, and the euphonic form before て and た (効い, 待っ, 読ん). す has none of its own.
|
|
9
|
+
*/
|
|
10
|
+
const GODAN_FORMS: Record<string, string> = {
|
|
11
|
+
う: "わいうえおっ",
|
|
12
|
+
く: "かきくけこい",
|
|
13
|
+
ぐ: "がぎぐげごい",
|
|
14
|
+
す: "さしすせそ",
|
|
15
|
+
つ: "たちつてとっ",
|
|
16
|
+
ぬ: "なにぬねのん",
|
|
17
|
+
ぶ: "ばびぶべぼん",
|
|
18
|
+
む: "まみむめもん",
|
|
19
|
+
る: "らりるれろっ",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Letters of a script written with spaces between words, where a word is matched whole. */
|
|
23
|
+
const SPACED = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}]/u;
|
|
24
|
+
|
|
25
|
+
/** What an entry looks for, as a regular expression with the `u` and `i` flags. */
|
|
26
|
+
function source(entry: Entry): string {
|
|
27
|
+
if (entry.pattern !== undefined) {
|
|
28
|
+
return entry.pattern;
|
|
29
|
+
}
|
|
30
|
+
const stem = literal(entry.word.slice(0, -1));
|
|
31
|
+
if (entry.verb === "godan") {
|
|
32
|
+
return `${stem}[${GODAN_FORMS[entry.word.at(-1) ?? ""] ?? ""}]`;
|
|
33
|
+
}
|
|
34
|
+
if (entry.verb === "ichidan") {
|
|
35
|
+
return stem;
|
|
36
|
+
}
|
|
37
|
+
const word = literal(entry.word);
|
|
38
|
+
const opens = SPACED.test(entry.word[0] ?? "") ? `(?<!${SPACED.source})` : "";
|
|
39
|
+
const closes = SPACED.test(entry.word.at(-1) ?? "") ? `(?!${SPACED.source})` : "";
|
|
40
|
+
return `${opens}${word}${closes}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Where `entry` is in a block's text, leaving out each place inside a word it excepts. */
|
|
44
|
+
export function finder(entry: Entry): (text: string) => Found[] {
|
|
45
|
+
const pattern = new RegExp(source(entry), "giu");
|
|
46
|
+
const excepts = entry.except.map((word) => new RegExp(literal(word), "giu"));
|
|
47
|
+
return (text) => {
|
|
48
|
+
const excepted = excepts.flatMap((except) =>
|
|
49
|
+
[...text.matchAll(except)].map((match) => [match.index, match.index + match[0].length]),
|
|
50
|
+
);
|
|
51
|
+
return [...text.matchAll(pattern)].flatMap((match): Found[] => {
|
|
52
|
+
const start = match.index;
|
|
53
|
+
const end = start + match[0].length;
|
|
54
|
+
if (match[0] === "" || excepted.some(([from = 0, to = 0]) => from <= start && end <= to)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return [{ index: start, text: match[0] }];
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CheckOutput } from "../../plugin-api.ts";
|
|
2
|
+
import type { Finding } from "./check.ts";
|
|
3
|
+
import type { RuleId } from "./manifest.ts";
|
|
4
|
+
import { MANIFEST } from "./manifest.ts";
|
|
5
|
+
|
|
6
|
+
/** Findings as the SARIF dek reads: the rule, the message, the place, and the hint. */
|
|
7
|
+
export function toSarif(findings: Finding[]): CheckOutput<RuleId> {
|
|
8
|
+
return {
|
|
9
|
+
version: "2.1.0",
|
|
10
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
11
|
+
runs: [
|
|
12
|
+
{
|
|
13
|
+
tool: { driver: { name: "dek-words", version: MANIFEST.version } },
|
|
14
|
+
results: findings.map((finding) => ({
|
|
15
|
+
ruleId: finding.rule,
|
|
16
|
+
message: { text: finding.message },
|
|
17
|
+
locations: [
|
|
18
|
+
{
|
|
19
|
+
physicalLocation: {
|
|
20
|
+
artifactLocation: { uri: finding.path },
|
|
21
|
+
...(finding.line === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: {
|
|
24
|
+
region: {
|
|
25
|
+
startLine: finding.line,
|
|
26
|
+
...(finding.column === undefined ? {} : { startColumn: finding.column }),
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
properties: { hint: finding.hint },
|
|
33
|
+
})),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|