chaffjs 0.21.0 → 0.22.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 (214) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-text.d.ts.map +1 -1
  3. package/dist/cli-text.js +4 -0
  4. package/dist/cli-text.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +4 -11
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/init-command.d.ts +4 -0
  9. package/dist/commands/init-command.d.ts.map +1 -0
  10. package/dist/commands/init-command.js +15 -0
  11. package/dist/commands/init-command.js.map +1 -0
  12. package/dist/commands/plugin-init.d.ts +20 -0
  13. package/dist/commands/plugin-init.d.ts.map +1 -0
  14. package/dist/commands/plugin-init.js +129 -0
  15. package/dist/commands/plugin-init.js.map +1 -0
  16. package/dist/commands/plugin-test.d.ts +11 -0
  17. package/dist/commands/plugin-test.d.ts.map +1 -0
  18. package/dist/commands/plugin-test.js +81 -0
  19. package/dist/commands/plugin-test.js.map +1 -0
  20. package/dist/custom/problems.d.ts.map +1 -1
  21. package/dist/custom/problems.js +1 -2
  22. package/dist/custom/problems.js.map +1 -1
  23. package/dist/detectors/amount-for-count.d.ts +21 -0
  24. package/dist/detectors/amount-for-count.d.ts.map +1 -0
  25. package/dist/detectors/amount-for-count.js +55 -0
  26. package/dist/detectors/amount-for-count.js.map +1 -0
  27. package/dist/detectors/currency-notation.d.ts +29 -0
  28. package/dist/detectors/currency-notation.d.ts.map +1 -0
  29. package/dist/detectors/currency-notation.js +58 -0
  30. package/dist/detectors/currency-notation.js.map +1 -0
  31. package/dist/detectors/dangling-opener.d.ts +14 -0
  32. package/dist/detectors/dangling-opener.d.ts.map +1 -0
  33. package/dist/detectors/dangling-opener.js +55 -0
  34. package/dist/detectors/dangling-opener.js.map +1 -0
  35. package/dist/detectors/footnote-mismatch.d.ts +11 -0
  36. package/dist/detectors/footnote-mismatch.d.ts.map +1 -0
  37. package/dist/detectors/footnote-mismatch.js +55 -0
  38. package/dist/detectors/footnote-mismatch.js.map +1 -0
  39. package/dist/detectors/form-minority.d.ts +13 -0
  40. package/dist/detectors/form-minority.d.ts.map +1 -0
  41. package/dist/detectors/form-minority.js +19 -0
  42. package/dist/detectors/form-minority.js.map +1 -0
  43. package/dist/detectors/heading-end-mark.d.ts +10 -0
  44. package/dist/detectors/heading-end-mark.d.ts.map +1 -0
  45. package/dist/detectors/heading-end-mark.js +59 -0
  46. package/dist/detectors/heading-end-mark.js.map +1 -0
  47. package/dist/detectors/list-marker.d.ts +7 -0
  48. package/dist/detectors/list-marker.d.ts.map +1 -0
  49. package/dist/detectors/list-marker.js +50 -0
  50. package/dist/detectors/list-marker.js.map +1 -0
  51. package/dist/detectors/markdown-slip.d.ts +19 -0
  52. package/dist/detectors/markdown-slip.d.ts.map +1 -0
  53. package/dist/detectors/markdown-slip.js +122 -0
  54. package/dist/detectors/markdown-slip.js.map +1 -0
  55. package/dist/detectors/numeral-opener.d.ts +10 -0
  56. package/dist/detectors/numeral-opener.d.ts.map +1 -0
  57. package/dist/detectors/numeral-opener.js +65 -0
  58. package/dist/detectors/numeral-opener.js.map +1 -0
  59. package/dist/detectors/ordinal-suffix.d.ts +12 -0
  60. package/dist/detectors/ordinal-suffix.d.ts.map +1 -0
  61. package/dist/detectors/ordinal-suffix.js +33 -0
  62. package/dist/detectors/ordinal-suffix.js.map +1 -0
  63. package/dist/detectors/range-notation.d.ts +15 -0
  64. package/dist/detectors/range-notation.d.ts.map +1 -0
  65. package/dist/detectors/range-notation.js +58 -0
  66. package/dist/detectors/range-notation.js.map +1 -0
  67. package/dist/detectors/registry/amount-for-count.d.ts +3 -0
  68. package/dist/detectors/registry/amount-for-count.d.ts.map +1 -0
  69. package/dist/detectors/registry/amount-for-count.js +3 -0
  70. package/dist/detectors/registry/amount-for-count.js.map +1 -0
  71. package/dist/detectors/registry/currency-notation.d.ts +3 -0
  72. package/dist/detectors/registry/currency-notation.d.ts.map +1 -0
  73. package/dist/detectors/registry/currency-notation.js +3 -0
  74. package/dist/detectors/registry/currency-notation.js.map +1 -0
  75. package/dist/detectors/registry/dangling-opener.d.ts +3 -0
  76. package/dist/detectors/registry/dangling-opener.d.ts.map +1 -0
  77. package/dist/detectors/registry/dangling-opener.js +3 -0
  78. package/dist/detectors/registry/dangling-opener.js.map +1 -0
  79. package/dist/detectors/registry/footnote-mismatch.d.ts +3 -0
  80. package/dist/detectors/registry/footnote-mismatch.d.ts.map +1 -0
  81. package/dist/detectors/registry/footnote-mismatch.js +3 -0
  82. package/dist/detectors/registry/footnote-mismatch.js.map +1 -0
  83. package/dist/detectors/registry/heading-end-punctuation.d.ts +3 -0
  84. package/dist/detectors/registry/heading-end-punctuation.d.ts.map +1 -0
  85. package/dist/detectors/registry/heading-end-punctuation.js +3 -0
  86. package/dist/detectors/registry/heading-end-punctuation.js.map +1 -0
  87. package/dist/detectors/registry/list-marker.d.ts +3 -0
  88. package/dist/detectors/registry/list-marker.d.ts.map +1 -0
  89. package/dist/detectors/registry/list-marker.js +3 -0
  90. package/dist/detectors/registry/list-marker.js.map +1 -0
  91. package/dist/detectors/registry/numeral-opener.d.ts +3 -0
  92. package/dist/detectors/registry/numeral-opener.d.ts.map +1 -0
  93. package/dist/detectors/registry/numeral-opener.js +3 -0
  94. package/dist/detectors/registry/numeral-opener.js.map +1 -0
  95. package/dist/detectors/registry/ordinal-suffix.d.ts +3 -0
  96. package/dist/detectors/registry/ordinal-suffix.d.ts.map +1 -0
  97. package/dist/detectors/registry/ordinal-suffix.js +3 -0
  98. package/dist/detectors/registry/ordinal-suffix.js.map +1 -0
  99. package/dist/detectors/registry/range-notation.d.ts +3 -0
  100. package/dist/detectors/registry/range-notation.d.ts.map +1 -0
  101. package/dist/detectors/registry/range-notation.js +3 -0
  102. package/dist/detectors/registry/range-notation.js.map +1 -0
  103. package/dist/detectors/registry/relative-pronoun-mix.d.ts +3 -0
  104. package/dist/detectors/registry/relative-pronoun-mix.d.ts.map +1 -0
  105. package/dist/detectors/registry/relative-pronoun-mix.js +3 -0
  106. package/dist/detectors/registry/relative-pronoun-mix.js.map +1 -0
  107. package/dist/detectors/registry/share-over-hundred.d.ts +3 -0
  108. package/dist/detectors/registry/share-over-hundred.d.ts.map +1 -0
  109. package/dist/detectors/registry/share-over-hundred.js +3 -0
  110. package/dist/detectors/registry/share-over-hundred.js.map +1 -0
  111. package/dist/detectors/registry/step-reference.d.ts +3 -0
  112. package/dist/detectors/registry/step-reference.d.ts.map +1 -0
  113. package/dist/detectors/registry/step-reference.js +3 -0
  114. package/dist/detectors/registry/step-reference.js.map +1 -0
  115. package/dist/detectors/registry/table-row-overflow.d.ts +3 -0
  116. package/dist/detectors/registry/table-row-overflow.d.ts.map +1 -0
  117. package/dist/detectors/registry/table-row-overflow.js +3 -0
  118. package/dist/detectors/registry/table-row-overflow.js.map +1 -0
  119. package/dist/detectors/registry/toc-heading.d.ts +3 -0
  120. package/dist/detectors/registry/toc-heading.d.ts.map +1 -0
  121. package/dist/detectors/registry/toc-heading.js +3 -0
  122. package/dist/detectors/registry/toc-heading.js.map +1 -0
  123. package/dist/detectors/registry/unclosed-code-fence.d.ts +3 -0
  124. package/dist/detectors/registry/unclosed-code-fence.d.ts.map +1 -0
  125. package/dist/detectors/registry/unclosed-code-fence.js +3 -0
  126. package/dist/detectors/registry/unclosed-code-fence.js.map +1 -0
  127. package/dist/detectors/registry/unrendered-emphasis.d.ts +3 -0
  128. package/dist/detectors/registry/unrendered-emphasis.d.ts.map +1 -0
  129. package/dist/detectors/registry/unrendered-emphasis.js +3 -0
  130. package/dist/detectors/registry/unrendered-emphasis.js.map +1 -0
  131. package/dist/detectors/relative-pronoun-mix.d.ts +19 -0
  132. package/dist/detectors/relative-pronoun-mix.d.ts.map +1 -0
  133. package/dist/detectors/relative-pronoun-mix.js +60 -0
  134. package/dist/detectors/relative-pronoun-mix.js.map +1 -0
  135. package/dist/detectors/share-over-hundred.d.ts +20 -0
  136. package/dist/detectors/share-over-hundred.d.ts.map +1 -0
  137. package/dist/detectors/share-over-hundred.js +55 -0
  138. package/dist/detectors/share-over-hundred.js.map +1 -0
  139. package/dist/detectors/step-reference.d.ts +20 -0
  140. package/dist/detectors/step-reference.d.ts.map +1 -0
  141. package/dist/detectors/step-reference.js +57 -0
  142. package/dist/detectors/step-reference.js.map +1 -0
  143. package/dist/detectors/toc-heading.d.ts +10 -0
  144. package/dist/detectors/toc-heading.d.ts.map +1 -0
  145. package/dist/detectors/toc-heading.js +99 -0
  146. package/dist/detectors/toc-heading.js.map +1 -0
  147. package/dist/list-marks.d.ts +21 -0
  148. package/dist/list-marks.d.ts.map +1 -0
  149. package/dist/list-marks.js +82 -0
  150. package/dist/list-marks.js.map +1 -0
  151. package/dist/rule-fields.d.ts +5 -0
  152. package/dist/rule-fields.d.ts.map +1 -1
  153. package/dist/rule-fields.js +21 -0
  154. package/dist/rule-fields.js.map +1 -1
  155. package/package.json +3 -3
  156. package/rules/cliche.yaml +67 -0
  157. package/rules/currency-notation-consistency.yaml +77 -0
  158. package/rules/dangling-participle.yaml +58 -0
  159. package/rules/footnote-mismatch.yaml +91 -0
  160. package/rules/heading-end-punctuation-mix.yaml +158 -0
  161. package/rules/less-for-fewer.yaml +59 -0
  162. package/rules/list-marker-mix.yaml +111 -0
  163. package/rules/nonstandard-word.yaml +80 -0
  164. package/rules/numeral-sentence-start.yaml +56 -0
  165. package/rules/ordinal-suffix-mismatch.yaml +65 -0
  166. package/rules/range-notation-consistency.yaml +73 -0
  167. package/rules/share-over-hundred.yaml +65 -0
  168. package/rules/step-reference-missing.yaml +85 -0
  169. package/rules/table-row-overflow.yaml +90 -0
  170. package/rules/toc-heading-mismatch.yaml +136 -0
  171. package/rules/unclosed-code-fence.yaml +107 -0
  172. package/rules/uncomparable-graded.yaml +65 -0
  173. package/rules/unrendered-emphasis.yaml +73 -0
  174. package/rules/which-that-consistency.yaml +64 -0
  175. package/skills/chaff/SKILL.md +7 -7
  176. package/src/cli-text.ts +4 -0
  177. package/src/cli.ts +5 -9
  178. package/src/commands/init-command.ts +13 -0
  179. package/src/commands/plugin-init.ts +150 -0
  180. package/src/commands/plugin-test.ts +111 -0
  181. package/src/custom/problems.ts +1 -3
  182. package/src/detectors/amount-for-count.ts +90 -0
  183. package/src/detectors/currency-notation.ts +90 -0
  184. package/src/detectors/dangling-opener.ts +74 -0
  185. package/src/detectors/footnote-mismatch.ts +72 -0
  186. package/src/detectors/form-minority.ts +21 -0
  187. package/src/detectors/heading-end-mark.ts +82 -0
  188. package/src/detectors/list-marker.ts +60 -0
  189. package/src/detectors/markdown-slip.ts +140 -0
  190. package/src/detectors/numeral-opener.ts +72 -0
  191. package/src/detectors/ordinal-suffix.ts +40 -0
  192. package/src/detectors/range-notation.ts +76 -0
  193. package/src/detectors/registry/amount-for-count.ts +4 -0
  194. package/src/detectors/registry/currency-notation.ts +4 -0
  195. package/src/detectors/registry/dangling-opener.ts +4 -0
  196. package/src/detectors/registry/footnote-mismatch.ts +4 -0
  197. package/src/detectors/registry/heading-end-punctuation.ts +4 -0
  198. package/src/detectors/registry/list-marker.ts +4 -0
  199. package/src/detectors/registry/numeral-opener.ts +4 -0
  200. package/src/detectors/registry/ordinal-suffix.ts +4 -0
  201. package/src/detectors/registry/range-notation.ts +4 -0
  202. package/src/detectors/registry/relative-pronoun-mix.ts +4 -0
  203. package/src/detectors/registry/share-over-hundred.ts +4 -0
  204. package/src/detectors/registry/step-reference.ts +4 -0
  205. package/src/detectors/registry/table-row-overflow.ts +4 -0
  206. package/src/detectors/registry/toc-heading.ts +4 -0
  207. package/src/detectors/registry/unclosed-code-fence.ts +4 -0
  208. package/src/detectors/registry/unrendered-emphasis.ts +4 -0
  209. package/src/detectors/relative-pronoun-mix.ts +79 -0
  210. package/src/detectors/share-over-hundred.ts +75 -0
  211. package/src/detectors/step-reference.ts +83 -0
  212. package/src/detectors/toc-heading.ts +125 -0
  213. package/src/list-marks.ts +106 -0
  214. package/src/rule-fields.ts +28 -0
@@ -0,0 +1,150 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { API_VERSION } from "../api.ts";
4
+ import { VERSION } from "../version.ts";
5
+ import type { Texts, UiLanguage } from "../ui.ts";
6
+
7
+ // chaff init --plugin <name>: a rule pack to start from, written in YAML alone. One rule, its word list in Japanese and
8
+ // English, and a test command (chaff plugin-test) that checks each rule's example against the rule.
9
+
10
+ /** A pack's own name: lowercase letters, digits and hyphens, optionally under a scope (@team/house). */
11
+ const PACK_NAME = /^(?:(@[a-z0-9][a-z0-9._-]*)\/)?([a-z][a-z0-9-]*)$/u;
12
+
13
+ export type PackName = { readonly scope: string | undefined; readonly name: string };
14
+
15
+ /** The name as written after --plugin, or undefined when it cannot be a plugin's name. "chaff-plugin-" in front is allowed and dropped. */
16
+ export const packNameOf = (written: string | undefined): PackName | undefined => {
17
+ const match = PACK_NAME.exec((written ?? "").replace(/(^|\/)chaff-plugin-/u, "$1"));
18
+ if (match === null) return undefined;
19
+ const [, scope, name] = match;
20
+ return name === undefined ? undefined : { scope, name };
21
+ };
22
+
23
+ /** The package's name and the folder it is made in: chaff-plugin-house, or @team/chaff-plugin-house in chaff-plugin-house. */
24
+ export const packageOf = (pack: PackName): { readonly packageName: string; readonly folder: string } => ({
25
+ packageName: pack.scope === undefined ? `chaff-plugin-${pack.name}` : `${pack.scope}/chaff-plugin-${pack.name}`,
26
+ folder: `chaff-plugin-${pack.name}`,
27
+ });
28
+
29
+ const RULE = `# One rule of the pack. It is written as a rule under custom_rules in chaff.yaml is; see
30
+ # https://isamu.github.io/lab/en/guide/adding-rules for every field.
31
+ id: avoid-words
32
+ type: words
33
+ # The words come from lexicons/<language>/avoid-words.yaml, in the document's language.
34
+ word_list: avoid-words
35
+ name: { ja: チームで使わない言葉, en: A word the team does not use }
36
+ why:
37
+ ja: このチームでは、同じものを決まった言葉で呼びます。別の言葉が混ざると、読む人は別のものかと迷います。
38
+ en: The team calls each thing by one word. Another word makes a reader wonder whether it is another thing.
39
+ how_to_fix: { ja: チームの言葉に置き換えます。, en: Use the team's word. }
40
+ message:
41
+ ja: 「{matched}」ではなく「{preferred}」と書きます
42
+ en: 'Write "{preferred}", not "{matched}"'
43
+ level: warning
44
+ example:
45
+ ja: { before: 弊社の新しい窓口をご案内します。, after: 当社の新しい窓口をご案内します。 }
46
+ en: { before: We utilize the new form for every request., after: We use the new form for every request. }
47
+ rewrite:
48
+ depth: light
49
+ ja:
50
+ direction: 指された言葉だけを、チームの言葉に置き換えます。
51
+ pairs:
52
+ - { before: 弊社の新しい窓口をご案内します。, after: 当社の新しい窓口をご案内します。 }
53
+ - { before: 弊社では毎月点検しています。, after: 当社では毎月点検しています。 }
54
+ keep: [文のほかの部分]
55
+ avoid: [言葉と一緒に文の意味を変える]
56
+ en:
57
+ direction: Replace only the flagged word with the team's word.
58
+ pairs:
59
+ - { before: We utilize the new form for every request., after: We use the new form for every request. }
60
+ - { before: Utilize the search box first., after: Use the search box first. }
61
+ keep: [the rest of the sentence]
62
+ avoid: [changing what the sentence says along with the word]
63
+ `;
64
+
65
+ const LEXICON_JA = `# 使わない言葉(pattern)と、使う言葉(rewrite)。
66
+ - { pattern: 弊社, rewrite: 当社 }
67
+ `;
68
+
69
+ const LEXICON_EN = `# The word not to use (pattern) and the word to use (rewrite).
70
+ - { pattern: utilize, rewrite: use }
71
+ `;
72
+
73
+ const packageJson = (packageName: string): string =>
74
+ `${JSON.stringify(
75
+ {
76
+ name: packageName,
77
+ version: "0.1.0",
78
+ description: "Rules for chaff, written in YAML.",
79
+ files: ["rules", "lexicons", "styles"],
80
+ chaff: { apiVersion: API_VERSION },
81
+ scripts: { test: "chaffjs plugin-test ." },
82
+ peerDependencies: { chaffjs: `>=${VERSION}` },
83
+ },
84
+ null,
85
+ 2,
86
+ )}\n`;
87
+
88
+ const FILES = (packageName: string): Readonly<Record<string, string>> => ({
89
+ "package.json": packageJson(packageName),
90
+ "rules/avoid-words.yaml": RULE,
91
+ "lexicons/ja/avoid-words.yaml": LEXICON_JA,
92
+ "lexicons/en/avoid-words.yaml": LEXICON_EN,
93
+ });
94
+
95
+ const TEXT: Texts<{
96
+ readonly badName: (written: string) => string;
97
+ readonly exists: (folder: string) => string;
98
+ readonly made: (folder: string, packageName: string) => string;
99
+ readonly next: (folder: string, name: string) => readonly string[];
100
+ }> = {
101
+ ja: {
102
+ badName: (written) => `--plugin ${written}: プラグインの名前は英小文字・数字・ハイフンで書きます(house、@team/house)`,
103
+ exists: (folder) => `${folder} はもうあります。上書きしないので、別の名前にするか消してから実行してください`,
104
+ made: (folder, packageName) => `${folder}/ に YAML だけのルールの束 ${packageName} を作りました。`,
105
+ next: (folder, name) => [
106
+ "",
107
+ "次に:",
108
+ ` cd ${folder} && npx chaffjs plugin-test . ルールの例をルールにかけて確かめる`,
109
+ ` chaff.yaml に plugins: [./${folder}] と書くと、${name}/avoid-words として動きます`,
110
+ "",
111
+ ],
112
+ },
113
+ en: {
114
+ badName: (written) => `--plugin ${written}: a plugin's name is lowercase letters, digits and hyphens (house, @team/house)`,
115
+ exists: (folder) => `${folder} already exists. Nothing is overwritten; choose another name or remove it first`,
116
+ made: (folder, packageName) => `Made the YAML rule pack ${packageName} in ${folder}/.`,
117
+ next: (folder, name) => [
118
+ "",
119
+ "Next:",
120
+ ` cd ${folder} && npx chaffjs plugin-test . check each rule against its own example`,
121
+ ` add plugins: [./${folder}] to chaff.yaml and it runs as ${name}/avoid-words`,
122
+ "",
123
+ ],
124
+ },
125
+ };
126
+
127
+ /** Makes the pack in dir. Lines to print, and whether it was made; nothing is written when the folder is already there. */
128
+ export const initPlugin = (dir: string, written: string | undefined, ui: UiLanguage): { readonly ok: boolean; readonly lines: readonly string[] } => {
129
+ const text = TEXT[ui];
130
+ const pack = packNameOf(written);
131
+ if (pack === undefined) return { ok: false, lines: [text.badName(written ?? "")] };
132
+ const { packageName, folder } = packageOf(pack);
133
+ if (existsSync(join(dir, folder))) return { ok: false, lines: [text.exists(folder)] };
134
+ Object.entries(FILES(packageName)).forEach(([path, body]) => {
135
+ mkdirSync(dirname(join(dir, folder, path)), { recursive: true });
136
+ writeFileSync(join(dir, folder, path), body, "utf8");
137
+ });
138
+ const name = pack.scope === undefined ? pack.name : `${pack.scope}/${pack.name}`;
139
+ return {
140
+ ok: true,
141
+ lines: [text.made(folder, packageName), ...Object.keys(FILES(packageName)).map((path) => ` ${folder}/${path}`), ...text.next(folder, name)],
142
+ };
143
+ };
144
+
145
+ /** chaff init --plugin <name>: makes the pack in dir and says what it made, or why it did not. */
146
+ export const runInitPlugin = (dir: string, written: string | undefined, ui: UiLanguage): number => {
147
+ const made = initPlugin(dir, written, ui);
148
+ made.lines.forEach((line) => (made.ok ? console.log(line) : console.error(line)));
149
+ return made.ok ? 0 : 1;
150
+ };
@@ -0,0 +1,111 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { EMPTY, type Config } from "../config/load.ts";
5
+ import { withExtensions } from "../extension/load.ts";
6
+ import { extensionProblems } from "../extension/problem-text.ts";
7
+ import { loadGenres } from "../genre-load.ts";
8
+ import type { RuleDefinition } from "../plugin.ts";
9
+ import type { Skipped } from "../run.ts";
10
+ import type { Texts, UiLanguage } from "../ui.ts";
11
+ import type { Checked } from "./feedback.ts";
12
+
13
+ // chaff plugin-test [folder]: loads a plugin as chaff.yaml would, then runs each of its rules on the rule's own example,
14
+ // in every language the example is written in. The before must give the rule a finding and the after must give it
15
+ // none. A rule that does not run there (no word list for the language) fails, with the reason.
16
+
17
+ export type PluginTestContext = {
18
+ readonly ui: UiLanguage;
19
+ /** Lints one file with these settings, as chaff on the command line does. */
20
+ readonly check: (path: string, config: Config, argv: readonly string[]) => Promise<Pick<Checked, "findings" | "skipped">>;
21
+ };
22
+
23
+ type Outcome = { readonly rule: string; readonly language: string; readonly failure: string | undefined };
24
+
25
+ const TEXT: Texts<{
26
+ readonly noRules: (dir: string) => string;
27
+ readonly beforeQuiet: string;
28
+ readonly afterFlagged: (count: number) => string;
29
+ readonly notRun: (why: string) => string;
30
+ readonly noExample: string;
31
+ readonly summary: (passed: number, total: number) => string;
32
+ }> = {
33
+ ja: {
34
+ noRules: (dir) => `${dir} にルールがありません。`,
35
+ beforeQuiet: "例の before で指摘が出ません",
36
+ afterFlagged: (count) => `例の after で指摘が ${String(count)} 件出ます`,
37
+ notRun: (why) => `動きません(${why})`,
38
+ noExample: "例がありません",
39
+ summary: (passed, total) => `${String(total)} 件のうち ${String(passed)} 件が通りました。`,
40
+ },
41
+ en: {
42
+ noRules: (dir) => `${dir} has no rules.`,
43
+ beforeQuiet: "the example's before gets no finding",
44
+ afterFlagged: (count) => `the example's after gets ${String(count)} ${count === 1 ? "finding" : "findings"}`,
45
+ notRun: (why) => `it does not run (${why})`,
46
+ noExample: "it has no example",
47
+ summary: (passed, total) => `${String(passed)} of ${String(total)} passed.`,
48
+ },
49
+ };
50
+
51
+ /** The first genre the rule is for: the example runs where the rule does. */
52
+ const genreFor = (rule: RuleDefinition): string => {
53
+ const genres = loadGenres().genres.map((genre) => genre.id);
54
+ return genres.find((genre) => rule.use_for.some((target) => genre === target || genre.startsWith(`${target}/`))) ?? genres[0] ?? "business/report";
55
+ };
56
+
57
+ const skippedWhy = (skipped: readonly Skipped[], rule: string): string | undefined => skipped.find((entry) => entry.rule === rule)?.why;
58
+
59
+ /** One rule in one language: before flagged, after clean. */
60
+ const testExample = async (rule: RuleDefinition, language: string, config: Config, context: PluginTestContext): Promise<Outcome> => {
61
+ const text = TEXT[context.ui];
62
+ const example = rule.guide?.examples[language];
63
+ if (example === undefined) return { rule: rule.id, language, failure: text.noExample };
64
+ const dir = mkdtempSync(join(tmpdir(), "chaff-plugin-test-"));
65
+ const argv = ["--language", language, "--genre", genreFor(rule)];
66
+ const run = async (name: string, body: string): Promise<Pick<Checked, "findings" | "skipped">> => {
67
+ writeFileSync(join(dir, name), `${body.trimEnd()}\n`, "utf8");
68
+ return context.check(join(dir, name), config, argv);
69
+ };
70
+ const before = await run("before.md", example.before);
71
+ const why = skippedWhy(before.skipped, rule.id);
72
+ if (why !== undefined) return { rule: rule.id, language, failure: text.notRun(why) };
73
+ if (!before.findings.some((finding) => finding.rule === rule.id)) return { rule: rule.id, language, failure: text.beforeQuiet };
74
+ const after = (await run("after.md", example.after)).findings.filter((finding) => finding.rule === rule.id).length;
75
+ return { rule: rule.id, language, failure: after === 0 ? undefined : text.afterFlagged(after) };
76
+ };
77
+
78
+ const lineOf = (outcome: Outcome): string =>
79
+ outcome.failure === undefined ? ` ✓ ${outcome.rule} (${outcome.language})` : ` ✗ ${outcome.rule} (${outcome.language}): ${outcome.failure}`;
80
+
81
+ /** The languages a rule's example is written in, or the rule's languages when it has none, so a missing example is said. */
82
+ const languagesOf = (rule: RuleDefinition): readonly string[] => {
83
+ const written = Object.keys(rule.guide?.examples ?? {});
84
+ return written.length > 0 ? written : (rule.languages ?? ["ja", "en"]);
85
+ };
86
+
87
+ /** Exit 0 when every rule passes on every example; 1 when the plugin cannot be loaded or a rule fails. */
88
+ export const runPluginTest = async (folder: string | undefined, context: PluginTestContext): Promise<number> => {
89
+ const text = TEXT[context.ui];
90
+ const dir = resolve(folder ?? ".");
91
+ const config = await withExtensions({ ...EMPTY, plugins: [dir], baseDir: dir, path: dir });
92
+ const problems = extensionProblems(config, context.ui);
93
+ if (problems.length > 0) {
94
+ problems.forEach((problem) => console.error(problem));
95
+ return 1;
96
+ }
97
+ const rules = config.extensions?.rules ?? [];
98
+ if (rules.length === 0) {
99
+ console.error(text.noRules(dir));
100
+ return 1;
101
+ }
102
+ const cases = rules.flatMap((rule) => languagesOf(rule).map((language) => ({ rule, language })));
103
+ const outcomes = await cases.reduce<Promise<Outcome[]>>(
104
+ async (done, entry) => [...(await done), await testExample(entry.rule, entry.language, config, context)],
105
+ Promise.resolve([]),
106
+ );
107
+ outcomes.map(lineOf).forEach((line) => console.log(line));
108
+ const passed = outcomes.filter((outcome) => outcome.failure === undefined).length;
109
+ console.log(`\n${text.summary(passed, outcomes.length)}`);
110
+ return passed === outcomes.length ? 0 : 1;
111
+ };
@@ -5,7 +5,7 @@ import { customRulesOf } from "./load.ts";
5
5
  import { MAX_PATTERN_LENGTH, MAX_REPEATS, type RegexRefusal } from "./regex-safety.ts";
6
6
  import { POS_WRITTEN_NAMES } from "./token-pattern.ts";
7
7
  import type { ModulePathRefusal } from "./module-path.ts";
8
- import { fieldProblemSentence, type FieldKind, type FieldProblem } from "../rule-fields.ts";
8
+ import { FIELD_KINDS, fieldProblemSentence, type FieldKind, type FieldProblem } from "../rule-fields.ts";
9
9
 
10
10
  /** Each problem's sentence, with {at}, {written}, {field}, {index}, {refusal} and {names} filled in from the problem. */
11
11
  type Text = {
@@ -111,8 +111,6 @@ const valuesOf = (problem: CustomProblem, text: Text, scope: string): Readonly<R
111
111
  names: POS_WRITTEN_NAMES.join(" / "),
112
112
  });
113
113
 
114
- const FIELD_KINDS: ReadonlySet<string> = new Set<FieldKind>(["bad-depth", "bad-group", "bad-use-for", "bad-summary", "bad-example"]);
115
-
116
114
  const isFieldProblem = (problem: CustomProblem): problem is FieldProblem & { readonly at: string } => FIELD_KINDS.has(problem.kind);
117
115
 
118
116
  /** One problem as a sentence. scope names the list the rule is in: custom_rules, or a plugin's rules. */
@@ -0,0 +1,90 @@
1
+ import type { Detector, Finding, Lexicon, Token } from "../plugin.ts";
2
+ import { endsNounPhrase } from "./agreement-slip.ts";
3
+ import { isPartOfLongerWord } from "./doubled-word.ts";
4
+
5
+ /**
6
+ * 量を言う語を、数えられる名詞の複数に付けた所(less errors、less customer complaints)。語は語彙表 amount-quantifier が言う。
7
+ * 前が名詞か数なら「引く」の less(revenue less costs、$500 less fees)なので読まない。
8
+ */
9
+
10
+ export type AmountLists = {
11
+ /** 量を言う語(less)と、数を言う語(fewer, rewrite)。 */
12
+ readonly quantifiers: Lexicon;
13
+ /** 複数の形でも数えない名詞。単数と複数が同じ綴りの語(series)と、金額を言う複数(earnings)。 */
14
+ readonly uncounted: ReadonlySet<string>;
15
+ /** 名詞にならない be / have の形。名詞句がそこで終わる。 */
16
+ readonly finite: ReadonlySet<string>;
17
+ };
18
+
19
+ export type AmountSlip = { readonly quantifier: Token; readonly noun: Token; readonly rewrite: string };
20
+
21
+ const UPPER_START = /^\p{Lu}/u;
22
+
23
+ /** 前に立てば、量の語が限定詞として読める品詞。名詞・数・記号の後ろは「引く」の less。 */
24
+ const OPENS_PHRASE = new Set(["VERB", "AUX", "ADP", "CCONJ", "SCONJ", "ADV", "PART"]);
25
+ const OPENING_MARK = new Set(["(", "[", "{", '"', "'", "“", "‘"]);
26
+
27
+ const lower = (token: Token): string => token.surface.toLowerCase();
28
+
29
+ /** 解析器は三人称の動詞を複数の名詞と読むことがある(The parser reports less errors の reports)。 */
30
+ const mayBeVerb = (token: Token): boolean => token.pos === "NOUN" && token.features?.["AlsoVerb"] === "Yes" && token.features["Number"] === "Plur";
31
+
32
+ const opensPhrase = (previous: Token | undefined): boolean =>
33
+ previous === undefined || OPENS_PHRASE.has(previous.pos) || OPENING_MARK.has(previous.surface) || mayBeVerb(previous);
34
+
35
+ const isCommonNoun = (token: Token | undefined): token is Token =>
36
+ token?.pos === "NOUN" && token.features?.["Guess"] !== "Yes" && !UPPER_START.test(token.surface);
37
+
38
+ const isPlural = (token: Token): boolean => token.features?.["Number"] === "Plur";
39
+
40
+ /** 複数の形がはっきりした、数えられる普通名詞。形から当てた語(stimuli)と、数えない語は読まない。 */
41
+ const isCountedPlural = (noun: Token | undefined, uncounted: ReadonlySet<string>): noun is Token =>
42
+ isCommonNoun(noun) && isPlural(noun) && !uncounted.has(lower(noun));
43
+
44
+ /** less の後ろの名詞の頭。単数の名詞を一つ重ねた語(less customer complaints)は、その後ろの語。 */
45
+ const headAt = (tokens: readonly Token[], at: number): number => {
46
+ const next = tokens[at + 1];
47
+ return isCommonNoun(next) && !isPlural(next) ? at + 2 : at + 1;
48
+ };
49
+
50
+ const slipAt = (source: string, tokens: readonly Token[], at: number, lists: AmountLists): AmountSlip | undefined => {
51
+ const quantifier = tokens[at];
52
+ const entry = quantifier === undefined ? undefined : lists.quantifiers.find((candidate) => candidate.pattern.toLowerCase() === lower(quantifier));
53
+ if (quantifier?.pos !== "ADJ" || entry === undefined || !opensPhrase(tokens[at - 1])) return undefined;
54
+ const head = headAt(tokens, at);
55
+ const noun = tokens[head];
56
+ if (!isCountedPlural(noun, lists.uncounted) || !endsNounPhrase(tokens[head + 1], lists.finite) || isPartOfLongerWord(source, quantifier, noun))
57
+ return undefined;
58
+ return { quantifier, noun, rewrite: entry.rewrite ?? "" };
59
+ };
60
+
61
+ export const amountSlipsIn = (source: string, tokens: readonly Token[], lists: AmountLists): AmountSlip[] =>
62
+ tokens.flatMap((_, at) => slipAt(source, tokens, at, lists) ?? []);
63
+
64
+ const patternsOf = (lexicon: Lexicon): string[] => lexicon.map((entry) => entry.pattern.toLowerCase());
65
+
66
+ const listsOf = (lexicons: Readonly<Record<string, Lexicon>>): AmountLists => {
67
+ const words = lexicons["amount-quantifier"] ?? [];
68
+ return {
69
+ quantifiers: words.filter((entry) => entry.group === "quantifier"),
70
+ uncounted: new Set([...patternsOf(lexicons["invariant-noun"] ?? []), ...patternsOf(words.filter((entry) => entry.group === "amount"))]),
71
+ finite: new Set(patternsOf(lexicons["finite-auxiliary"] ?? [])),
72
+ };
73
+ };
74
+
75
+ export const amountForCount: Detector = (doc): Finding[] => {
76
+ const lists = listsOf(doc.lexicons);
77
+ return doc.sentences.flatMap((sentence) =>
78
+ amountSlipsIn(doc.source, sentence.tokens ?? [], lists).map((slip): Finding => {
79
+ const rest = doc.source.slice(slip.quantifier.span.end, slip.noun.span.end).replace(/\s+/gu, " ");
80
+ return {
81
+ rule: "",
82
+ severity: "warning",
83
+ line: 0,
84
+ column: 0,
85
+ quote: sentence.text.trim(),
86
+ values: { word: `${slip.quantifier.surface}${rest}`, suggestion: `${slip.rewrite}${rest}`, offset: slip.quantifier.span.start },
87
+ };
88
+ }),
89
+ );
90
+ };
@@ -0,0 +1,90 @@
1
+ import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
+ import { escapeRegExp } from "../orthography.ts";
3
+ import { formMinority } from "./form-minority.ts";
4
+ import { quoteAround } from "./quote-around.ts";
5
+
6
+ // 一つの文書で、同じ通貨の金額を二通りに書いた所(1,000円 と ¥1,000、$20 と 20 dollars)。どちらが正しいかは決めず、少ないほうを指す。
7
+ // 通貨の書き方、それを金額のどちら側に書くか、どの通貨か(group)は語彙表 currency-notation、桁の語は amount-multiplier が言う。
8
+
9
+ export type CurrencyForm = { readonly pattern: string; readonly position: "before" | "after"; readonly currency: string };
10
+
11
+ /** 金額 1 つ。form は書き方(側と記号)、currency はその通貨。 */
12
+ export type WrittenAmount = { readonly offset: number; readonly written: string; readonly form: string; readonly currency: string };
13
+
14
+ /** 少ないほうの書き方の金額と、多いほうの書き方の例、その通貨の金額の数。 */
15
+ export type CurrencyMinority = { readonly odd: WrittenAmount; readonly usual: string; readonly count: number; readonly of: number };
16
+
17
+ const AMOUNT = "[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?";
18
+
19
+ const alternation = (words: readonly string[]): string => words.map(escapeRegExp).join("|");
20
+
21
+ /** 記号が英字で始まる・終わるなら、語の中(USDT、dollarsign)を読まないよう英字の切れ目を求める。 */
22
+ const latinEdge = (pattern: string, side: "start" | "end"): string => {
23
+ const edge = side === "start" ? pattern.charAt(0) : pattern.charAt(pattern.length - 1);
24
+ if (!/[A-Za-z]/u.test(edge)) return "";
25
+ return side === "start" ? "(?<![A-Za-z])" : "(?![A-Za-z])";
26
+ };
27
+
28
+ const amountPattern = (form: CurrencyForm, multipliers: readonly string[]): RegExp => {
29
+ const mark = escapeRegExp(form.pattern);
30
+ const multiplier = multipliers.length === 0 ? "" : `(?:\\s?(?:${alternation(multipliers)}))?`;
31
+ return form.position === "before"
32
+ ? new RegExp(`${latinEdge(form.pattern, "start")}${mark}\\s?${AMOUNT}${multiplier}(?![A-Za-z0-90-9])`, "gu")
33
+ : new RegExp(`(?<![\\w.,,0-9])${AMOUNT}${multiplier}\\s?${mark}${latinEdge(form.pattern, "end")}`, "gu");
34
+ };
35
+
36
+ const overlaps = (left: WrittenAmount, right: WrittenAmount): boolean =>
37
+ left.offset < right.offset + right.written.length && right.offset < left.offset + left.written.length;
38
+
39
+ /** 文字列の中の金額と、その書き方。重なる所に二つの書き方が当たれば、記号の長いほう(US$ と $ なら US$、米ドル と ドル なら 米ドル)。 */
40
+ export const amountsIn = (text: string, forms: readonly CurrencyForm[], multipliers: readonly string[]): WrittenAmount[] =>
41
+ forms
42
+ .toSorted((left, right) => right.pattern.length - left.pattern.length)
43
+ .flatMap((form) =>
44
+ [...text.matchAll(amountPattern(form, multipliers))].map((match) => ({
45
+ offset: match.index,
46
+ written: match[0],
47
+ form: `${form.position}:${form.pattern}`,
48
+ currency: form.currency,
49
+ })),
50
+ )
51
+ .reduce<WrittenAmount[]>((kept, amount) => (kept.some((other) => overlaps(other, amount)) ? kept : [...kept, amount]), [])
52
+ .toSorted((left, right) => left.offset - right.offset);
53
+
54
+ const minorityOf = (ofCurrency: readonly WrittenAmount[], limit: number): CurrencyMinority[] => {
55
+ const minority = formMinority(ofCurrency, limit);
56
+ if (minority === undefined) return [];
57
+ return minority.odd.map((amount) => ({ odd: amount, usual: minority.usual.written, count: minority.odd.length, of: ofCurrency.length }));
58
+ };
59
+
60
+ /**
61
+ * 通貨ごとに、多いほうと違う書き方の金額。少ないほうが limit パーセントを超えれば、使い分けている文書と読んで言わない。
62
+ * いちばん多い書き方が同じ数で並べば、どちらにも立たない。
63
+ */
64
+ export const currencyMinorities = (amounts: readonly WrittenAmount[], limit: number): CurrencyMinority[] =>
65
+ [...new Set(amounts.map((amount) => amount.currency))]
66
+ .flatMap((currency) =>
67
+ minorityOf(
68
+ amounts.filter((amount) => amount.currency === currency),
69
+ limit,
70
+ ),
71
+ )
72
+ .toSorted((left, right) => left.odd.offset - right.odd.offset);
73
+
74
+ const formsOf = (doc: ProseDocument): CurrencyForm[] =>
75
+ (doc.lexicons["currency-notation"] ?? []).flatMap((entry) =>
76
+ entry.position === undefined || entry.group === undefined ? [] : [{ pattern: entry.pattern, position: entry.position, currency: entry.group }],
77
+ );
78
+
79
+ export const currencyNotation: Detector = (doc, options): Finding[] => {
80
+ const text = doc.prose ?? doc.source;
81
+ const multipliers = (doc.lexicons["amount-multiplier"] ?? []).map((entry) => entry.pattern);
82
+ return currencyMinorities(amountsIn(text, formsOf(doc), multipliers), options.limit).map(({ odd, usual, count, of }) => ({
83
+ rule: "",
84
+ severity: "info",
85
+ line: 0,
86
+ column: 0,
87
+ quote: quoteAround(text, odd.offset, odd.offset + odd.written.length),
88
+ values: { written: odd.written.trim(), usual: usual.trim(), count, of, offset: odd.offset },
89
+ }));
90
+ };
@@ -0,0 +1,74 @@
1
+ import type { Detector, Finding, Lexicon, Token } from "../plugin.ts";
2
+
3
+ /**
4
+ * 分詞で始めた句の後ろの主語が、形だけの it か there の文(Having reviewed the code, it is ready.)。
5
+ * 分詞の動作をしたのは書き手か読み手で、it ではない。読むのはこの形だけに絞り、主語が名詞の文は読まない。
6
+ * 読むのは -ing の分詞だけ。決まった言い方として読まれる分詞(Given, Based on, Generally speaking)は語彙表 dangling-participle の exempt が言う。
7
+ */
8
+
9
+ export type DanglingOpener = { readonly opener: string; readonly subject: Token; readonly offset: number };
10
+
11
+ /** 分詞の句として読む語の数の上限。長い句は読点の位置が当てにならない。 */
12
+ const MAX_OPENER_TOKENS = 12;
13
+ const BE = "be";
14
+ const HAVE = "have";
15
+ /** be の後ろで読点が来たら、it is … は挿入句(Testing the integration, it is worth noting, requires …)。 */
16
+ const PARENTHETICAL_REACH = 4;
17
+
18
+ const lower = (token: Token | undefined): string => token?.surface.toLowerCase() ?? "";
19
+
20
+ /** -ing の分詞だけ。過去分詞の句(Configured with SSO, it is ready.)は it そのものを言うことが多い。 */
21
+ const isPresentParticiple = (token: Token | undefined): boolean => token?.pos === "VERB" && token.features?.["VerbForm"] === "Ger";
22
+
23
+ const lemmaOf = (token: Token | undefined): string => token?.lemma?.toLowerCase() ?? "";
24
+
25
+ /** 文の頭が決まった言い方か。語彙表の語は空白で区切って、頭の語と順に比べる。 */
26
+ const opensWithExempt = (tokens: readonly Token[], exempt: readonly string[]): boolean =>
27
+ exempt.some((phrase) => phrase.split(/\s+/u).every((word, at) => lower(tokens[at]) === word));
28
+
29
+ /** 形だけの主語の後ろの be(it is / there are)、助動詞と be(it can be)、have と been(it has been)。 */
30
+ const isBeAfter = (tokens: readonly Token[], at: number): boolean => {
31
+ const verb = tokens[at];
32
+ if (lemmaOf(verb) === BE) return true;
33
+ return (verb?.pos === "AUX" || lemmaOf(verb) === HAVE) && lemmaOf(tokens[at + 1]) === BE;
34
+ };
35
+
36
+ const isParenthetical = (tokens: readonly Token[], at: number): boolean => tokens.slice(at, at + PARENTHETICAL_REACH).some((token) => token.surface === ",");
37
+
38
+ export const danglingOpenerIn = (
39
+ source: string,
40
+ tokens: readonly Token[],
41
+ subjects: ReadonlySet<string>,
42
+ exempt: readonly string[],
43
+ ): DanglingOpener | undefined => {
44
+ const [first] = tokens;
45
+ if (first === undefined || !isPresentParticiple(first) || opensWithExempt(tokens, exempt)) return undefined;
46
+ const comma = tokens.findIndex((token, at) => at > 0 && token.surface === ",");
47
+ const subject = tokens[comma + 1];
48
+ if (comma === -1 || comma > MAX_OPENER_TOKENS || subject === undefined || !subjects.has(lower(subject)) || !isBeAfter(tokens, comma + 2)) return undefined;
49
+ if (isParenthetical(tokens, comma + 3)) return undefined;
50
+ const last = tokens[comma - 1] ?? first;
51
+ return { opener: source.slice(first.span.start, last.span.end), subject, offset: first.span.start };
52
+ };
53
+
54
+ const patternsIn = (lexicon: Lexicon, group: string): string[] => lexicon.filter((entry) => entry.group === group).map((entry) => entry.pattern.toLowerCase());
55
+
56
+ export const danglingOpener: Detector = (doc): Finding[] => {
57
+ const words = doc.lexicons["dangling-participle"] ?? [];
58
+ const subjects = new Set(patternsIn(words, "subject"));
59
+ const exempt = patternsIn(words, "exempt");
60
+ return doc.sentences.flatMap((sentence) => {
61
+ const found = sentence.embeddedLanguage === undefined ? danglingOpenerIn(doc.source, sentence.tokens ?? [], subjects, exempt) : undefined;
62
+ if (found === undefined) return [];
63
+ return [
64
+ {
65
+ rule: "",
66
+ severity: "info",
67
+ line: 0,
68
+ column: 0,
69
+ quote: sentence.text.trim(),
70
+ values: { opener: found.opener, subject: found.subject.surface, offset: found.offset },
71
+ },
72
+ ];
73
+ });
74
+ };
@@ -0,0 +1,72 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+ import { escapeRegExp } from "../orthography.ts";
3
+ import { quoteAround } from "./quote-around.ts";
4
+
5
+ // 注の無い注の印と、本文が一度も付けない注。Markdown の脚注([^1] と行頭の [^1]:)と、語彙表 note-mark の印(※1 と行頭の ※1)を読む。
6
+
7
+ /** 合わない注 1 つ。missing は印に注が無い、unused は注に印が無い。label は書いたままの印([^1]、※1)。 */
8
+ export type NoteSlip = { readonly offset: number; readonly label: string; readonly reason: "missing" | "unused" };
9
+
10
+ /** 印 1 つ。kind は印の種類(markdown か note-mark の語)、key は番号、note は行の頭の注そのものか。 */
11
+ type NoteMark = { readonly offset: number; readonly label: string; readonly kind: string; readonly key: string; readonly note: boolean };
12
+
13
+ const MARKDOWN = "markdown";
14
+ const FULLWIDTH_OFFSET = 0xfee0;
15
+ /** 行の頭から印までに来てよいもの(字下げ、箇条書きの印、開き括弧)。 */
16
+ const NOTE_LINE_HEAD = /^[ \t]*(?:[-*+][ \t]+)?[((]?$/u;
17
+
18
+ const halfWidth = (text: string): string => text.replace(/[0-9]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
19
+
20
+ const lineHeadOf = (text: string, offset: number): string => text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset);
21
+
22
+ /** Markdown の脚注。名前は大文字と小文字を区別しない(CommonMark の参照と同じ)。 */
23
+ const markdownMarks = (text: string): NoteMark[] =>
24
+ [...text.matchAll(/\[\^(?<key>[^\]\s]+)\](?<colon>:)?/gu)].map((match) => {
25
+ const note = match.groups?.["colon"] !== undefined && /^[ \t]{0,3}$/u.test(lineHeadOf(text, match.index));
26
+ return { offset: match.index, label: `[^${match.groups?.["key"] ?? ""}]`, kind: MARKDOWN, key: (match.groups?.["key"] ?? "").toLowerCase(), note };
27
+ });
28
+
29
+ /** 印と一桁か二桁の番号。後ろに数字や小数が続けば年や数(※2018年度、※1.2倍)で、注の番号ではない。 */
30
+ const wordMarks = (text: string, mark: string): NoteMark[] =>
31
+ [...text.matchAll(new RegExp(`${escapeRegExp(mark)}(?<key>[0-90-9]{1,2})(?![0-90-9]|[..,,][0-90-9])`, "gu"))].map((match) => ({
32
+ offset: match.index,
33
+ label: match[0],
34
+ kind: mark,
35
+ key: halfWidth(match.groups?.["key"] ?? ""),
36
+ note: NOTE_LINE_HEAD.test(lineHeadOf(text, match.index)),
37
+ }));
38
+
39
+ /** 一つの種類の印のうち、合わないもの。印の語(※)は、その種類の注が一つも無ければ注を別に置いた文書と読んで言わない。 */
40
+ const slipsOfKind = (marks: readonly NoteMark[], kind: string): NoteSlip[] => {
41
+ const ofKind = marks.filter((mark) => mark.kind === kind);
42
+ const notes = new Set(ofKind.filter((mark) => mark.note).map((mark) => mark.key));
43
+ const referenced = new Set(ofKind.filter((mark) => !mark.note).map((mark) => mark.key));
44
+ if (notes.size === 0 && kind !== MARKDOWN) return [];
45
+ const missing = ofKind
46
+ .filter((mark) => !mark.note && !notes.has(mark.key))
47
+ .map((mark): NoteSlip => ({ offset: mark.offset, label: mark.label, reason: "missing" }));
48
+ const unused = ofKind
49
+ .filter((mark) => mark.note && !referenced.has(mark.key))
50
+ .map((mark): NoteSlip => ({ offset: mark.offset, label: mark.label, reason: "unused" }));
51
+ return [...missing, ...unused];
52
+ };
53
+
54
+ /** 文字列の中の、注の無い印と印の無い注。 */
55
+ export const noteSlips = (text: string, words: readonly string[]): NoteSlip[] => {
56
+ const marks = [...markdownMarks(text), ...words.flatMap((word) => wordMarks(text, word))];
57
+ return [...new Set(marks.map((mark) => mark.kind))].flatMap((kind) => slipsOfKind(marks, kind)).toSorted((left, right) => left.offset - right.offset);
58
+ };
59
+
60
+ export const footnoteMismatch: Detector = (doc): Finding[] => {
61
+ const text = doc.prose ?? doc.source;
62
+ const words = (doc.lexicons["note-mark"] ?? []).map((entry) => entry.pattern);
63
+ return noteSlips(text, words).map((slip) => ({
64
+ rule: "",
65
+ severity: "warning",
66
+ line: 0,
67
+ column: 0,
68
+ quote: quoteAround(text, slip.offset, slip.offset + slip.label.length),
69
+ values: { label: slip.label, offset: slip.offset },
70
+ variant: slip.reason,
71
+ }));
72
+ };
@@ -0,0 +1,21 @@
1
+ // 一つの文書の中で、同じものを何通りかに書いたときの少ないほう。どの書き方が正しいかは決めない。
2
+
3
+ /** 少ないほうの書き方のものと、いちばん多い書き方の例。 */
4
+ export type FormMinority<T> = { readonly odd: readonly T[]; readonly usual: T };
5
+
6
+ const PERCENT = 100;
7
+
8
+ /**
9
+ * いちばん多い書き方と違うもの。少ないほうが limit パーセントを超えれば使い分けている文書と読んで言わない。
10
+ * いちばん多い書き方が同じ数で並べば、どちらにも立たない。
11
+ */
12
+ export const formMinority = <T extends { readonly form: string }>(items: readonly T[], limit: number): FormMinority<T> | undefined => {
13
+ const counts = new Map<string, number>();
14
+ items.forEach((item) => counts.set(item.form, (counts.get(item.form) ?? 0) + 1));
15
+ const [top, second] = [...counts].toSorted((left, right) => right[1] - left[1]);
16
+ if (top === undefined || second === undefined || top[1] === second[1]) return undefined;
17
+ const odd = items.filter((item) => item.form !== top[0]);
18
+ const usual = items.find((item) => item.form === top[0]);
19
+ if (usual === undefined || odd.length * PERCENT > items.length * limit) return undefined;
20
+ return { odd, usual };
21
+ };