prosebook 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +594 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-CF7HziDa.d.ts +498 -0
- package/dist/config-CF7HziDa.d.ts.map +1 -0
- package/dist/config-CxVMn19f.js +1146 -0
- package/dist/config-CxVMn19f.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-PkPvsB07.js +1033 -0
- package/dist/sarif-PkPvsB07.js.map +1 -0
- package/dist/scan-B0tZ7y-D.js +2447 -0
- package/dist/scan-B0tZ7y-D.js.map +1 -0
- package/dist/tools.d.ts +351 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +238 -0
- package/dist/vite.js.map +1 -0
- package/package.json +146 -4
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +110 -0
- package/src/tools/config.ts +195 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +259 -0
- package/src/tools/icu.ts +182 -0
- package/src/tools/index.ts +78 -0
- package/src/tools/lint.ts +828 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +173 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +252 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds every source message to the mechanical half of a writing guide: budgets, periods, case,
|
|
3
|
+
* dashes, spelling, contractions, the product's one word per concept, and banned words. What needs
|
|
4
|
+
* judgment stays with review. An entry a rule is wrong about says so under `allow`, with the reason,
|
|
5
|
+
* and an exemption that no longer matches anything fails too, so none outlive the words they excused.
|
|
6
|
+
*
|
|
7
|
+
* The core rules are written as a plugin's are: a project adds its own through `lint.plugins`, as
|
|
8
|
+
* code or as data, and they read the same readings of a message and are exempted the same way.
|
|
9
|
+
*/
|
|
10
|
+
import type { Message } from './catalog.ts';
|
|
11
|
+
import type { ResolvedConfig } from './config.ts';
|
|
12
|
+
import { staticLiteral, TYPE, walk, type MessageFormatElement } from './icu.ts';
|
|
13
|
+
import type { KindRule, Severity } from './presets.ts';
|
|
14
|
+
|
|
15
|
+
/** What a rule is given about one message in one locale. */
|
|
16
|
+
export interface RuleContext {
|
|
17
|
+
/** The entry, as the source catalog has it. */
|
|
18
|
+
message: Message;
|
|
19
|
+
/** The locale read: the source, or a translation's. */
|
|
20
|
+
locale: string;
|
|
21
|
+
/** The message in that locale. */
|
|
22
|
+
ast: MessageFormatElement[];
|
|
23
|
+
/** The shape the message's kind is held to, when it names a kind the config defines. */
|
|
24
|
+
kind: KindRule | undefined;
|
|
25
|
+
/** The message in that locale, as prose. */
|
|
26
|
+
text: Readings;
|
|
27
|
+
/** The source message as prose, to hold a translation to it: `text` itself in the source locale. */
|
|
28
|
+
source: Readings;
|
|
29
|
+
config: ResolvedConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RuleBase {
|
|
33
|
+
/** One sentence: what the rule holds a string to. */
|
|
34
|
+
description: string;
|
|
35
|
+
/** Its severity under `extends: 'recommended'`; off when unset. */
|
|
36
|
+
recommended?: Severity;
|
|
37
|
+
/** Its severity under `extends: 'strict'`; `error` when unset. */
|
|
38
|
+
strict?: Severity;
|
|
39
|
+
/**
|
|
40
|
+
* Only entries of these kinds, or on these surfaces; an entry that says neither is skipped. Only
|
|
41
|
+
* the source unless `locale` says otherwise, since most writing rules are written for one language:
|
|
42
|
+
* `all` reads every locale, and a list those languages and their regions (`de` covers `de-CH`).
|
|
43
|
+
*/
|
|
44
|
+
on?: { kind?: string[]; surface?: string[]; locale?: 'all' | string[] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A rule written as code. */
|
|
48
|
+
export interface CheckRule extends RuleBase {
|
|
49
|
+
/** What is wrong with the message, a sentence each that states the fix; none when it passes. */
|
|
50
|
+
check(context: RuleContext): (string | Report)[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A finding that can say where it is, and what to write instead. */
|
|
54
|
+
export interface Report {
|
|
55
|
+
message: string;
|
|
56
|
+
/** The words the finding is about, as a reading has them: found in the message's text, in any case. */
|
|
57
|
+
match?: string;
|
|
58
|
+
/** What `prosebook lint --fix` writes in place of `match`, wherever it stands; its case follows the text's. */
|
|
59
|
+
fix?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A replacement in a message's text, by offset. */
|
|
63
|
+
export interface Edit {
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
text: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/*
|
|
70
|
+
* Rules written as data, after Vale's, so a JSON config can carry them. Words and patterns are looked
|
|
71
|
+
* for in every reading, so one in a `one` arm is found; counts and case read the `other` arms. A
|
|
72
|
+
* pattern is a regular expression with the `u` flag, and sees a placeholder as `HOLE`. A message
|
|
73
|
+
* names what was found with `{match}`, and the other fields its rule lists.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/** Words or patterns a string never uses. */
|
|
77
|
+
export interface ExistenceRule extends RuleBase {
|
|
78
|
+
type: 'existence';
|
|
79
|
+
/** Words or phrases, matched whole and in any case. `{match}` is the one listed. */
|
|
80
|
+
words?: string[];
|
|
81
|
+
/** `{match}` is the text matched. */
|
|
82
|
+
patterns?: string[];
|
|
83
|
+
ignoreCase?: boolean;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A word or phrase the product writes another way. */
|
|
88
|
+
export interface SubstitutionRule extends RuleBase {
|
|
89
|
+
type: 'substitution';
|
|
90
|
+
/** A word or phrase, matched whole and in any case, and the one written instead. */
|
|
91
|
+
swap: Record<string, string>;
|
|
92
|
+
/** `{match}` and `{use}`; `"{match}" is written "{use}"` when unset. */
|
|
93
|
+
message?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A budget, beside the one a kind sets: per surface, say, or in characters. */
|
|
97
|
+
export interface OccurrenceRule extends RuleBase {
|
|
98
|
+
type: 'occurrence';
|
|
99
|
+
/** Every word, placeholders included; sentences; or characters, placeholders not included. */
|
|
100
|
+
count: 'words' | 'sentences' | 'characters';
|
|
101
|
+
max?: number;
|
|
102
|
+
min?: number;
|
|
103
|
+
/** `max` and `min` are multiples of the source's count: `max: 1.4` is 40% longer than the source at most. */
|
|
104
|
+
relative?: boolean;
|
|
105
|
+
/** `{count}`, `{max}`, `{min}`, and with `relative`, `{source}` (the source's count) and `{limit}`. */
|
|
106
|
+
message?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface CapitalizationRule extends RuleBase {
|
|
110
|
+
type: 'capitalization';
|
|
111
|
+
/** `sentence` capitalizes only the first word and proper nouns, `title` every word but short function words. */
|
|
112
|
+
case: 'sentence' | 'title' | 'lower' | 'upper';
|
|
113
|
+
/** Words that keep their case, beside `lint.properNouns`. */
|
|
114
|
+
exceptions?: string[];
|
|
115
|
+
/** `{match}` is the first word out of case. */
|
|
116
|
+
message?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A string that matches `when` (every string, when unset) matches `require` too. */
|
|
120
|
+
export interface ConditionalRule extends RuleBase {
|
|
121
|
+
type: 'conditional';
|
|
122
|
+
when?: string;
|
|
123
|
+
require: string;
|
|
124
|
+
ignoreCase?: boolean;
|
|
125
|
+
/** `{match}` is the text `when` matched. */
|
|
126
|
+
message: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type Rule = CheckRule | ExistenceRule | SubstitutionRule | OccurrenceRule | CapitalizationRule | ConditionalRule;
|
|
130
|
+
|
|
131
|
+
/** A set of rules, named `<name>/<rule>` in `lint.rules` and in an entry's `allow`. */
|
|
132
|
+
export interface Plugin {
|
|
133
|
+
name: string;
|
|
134
|
+
rules: Record<string, Rule>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Typed plugin, for `lint.plugins`. */
|
|
138
|
+
export function definePlugin(plugin: Plugin): Plugin {
|
|
139
|
+
return plugin;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface Finding {
|
|
143
|
+
key: string;
|
|
144
|
+
/** The catalog file, or for a translation, the locale's file. */
|
|
145
|
+
file: string;
|
|
146
|
+
/** The locale whose text the finding is about; the source for an `allow` finding. */
|
|
147
|
+
locale: string;
|
|
148
|
+
/** The rule's name, or `allow` for an exemption that is itself wrong. */
|
|
149
|
+
rule: string;
|
|
150
|
+
severity: Exclude<Severity, 'off'>;
|
|
151
|
+
message: string;
|
|
152
|
+
/** Where in the locale's message text, when the rule named a match and it is written there as read. */
|
|
153
|
+
at?: { start: number; end: number };
|
|
154
|
+
/** The edits that fix it, at every place the match stands. */
|
|
155
|
+
fix?: Edit[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Stands in for a placeholder in a reading, which no prose rule reads. */
|
|
159
|
+
export const HOLE = '\u2063';
|
|
160
|
+
/** Tags whose content is code or a literal the reader types, not prose. */
|
|
161
|
+
const VERBATIM_TAGS = new Set(['code', 'mono', 'kbd', 'keycap', 'nums']);
|
|
162
|
+
|
|
163
|
+
function renderAs(elements: MessageFormatElement[], arm: string | null): string {
|
|
164
|
+
let out = '';
|
|
165
|
+
for (const el of elements) {
|
|
166
|
+
if (el.type === TYPE.literal) {
|
|
167
|
+
out += el.value;
|
|
168
|
+
} else if (el.type === TYPE.tag) {
|
|
169
|
+
out += VERBATIM_TAGS.has(el.value) ? HOLE : renderAs(el.children, arm);
|
|
170
|
+
} else if (el.type === TYPE.plural || el.type === TYPE.select) {
|
|
171
|
+
const option = arm === null ? undefined : (el.options[arm] ?? el.options['other']);
|
|
172
|
+
out += option === undefined ? HOLE : renderAs(option.value, arm);
|
|
173
|
+
} else {
|
|
174
|
+
out += HOLE;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The message as prose: placeholders and verbatim tags read as `HOLE`, and `’` as `'` in `variants`. */
|
|
181
|
+
export interface Readings {
|
|
182
|
+
/** Every `other` arm taken: what a budget is measured on. */
|
|
183
|
+
primary: string;
|
|
184
|
+
/** One reading per arm, so a word hidden in `one` is still found. */
|
|
185
|
+
variants: string[];
|
|
186
|
+
/** Only the words the message always carries. */
|
|
187
|
+
skeleton: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readings(message: MessageFormatElement[]): Readings {
|
|
191
|
+
const literal = staticLiteral(message);
|
|
192
|
+
const ast = literal === undefined ? message : ([{ type: TYPE.literal, value: literal }] as MessageFormatElement[]);
|
|
193
|
+
const arms = new Set<string>();
|
|
194
|
+
const collect = (els: MessageFormatElement[]) => {
|
|
195
|
+
for (const el of els) {
|
|
196
|
+
if (el.type === TYPE.plural || el.type === TYPE.select) {
|
|
197
|
+
for (const [name, option] of Object.entries(el.options)) {
|
|
198
|
+
arms.add(name);
|
|
199
|
+
collect(option.value);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (el.type === TYPE.tag) collect(el.children);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
collect(ast);
|
|
206
|
+
const primary = renderAs(ast, 'other');
|
|
207
|
+
const variants = new Set([primary, ...[...arms].map((arm) => renderAs(ast, arm))]);
|
|
208
|
+
return { primary, variants: [...variants].map((v) => v.replace(/\u2019/g, "'")), skeleton: renderAs(ast, null) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** What a reader sees end to end, less a placeholder appended at the very end. */
|
|
212
|
+
const prose = (text: string) => text.replace(new RegExp(`[\\s${HOLE}]+$`), '');
|
|
213
|
+
|
|
214
|
+
const FREE = new Set('a an the this that these those it its them your and or to of for by in on at from with'.split(' '));
|
|
215
|
+
|
|
216
|
+
const tokens = (text: string) => text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w) || w.includes(HOLE));
|
|
217
|
+
|
|
218
|
+
/** The words a budget counts: articles, short prepositions and asides in parentheses are free. */
|
|
219
|
+
export function words(text: string): string[] {
|
|
220
|
+
return tokens(text.replace(/\s*\([^)]*\)/g, '')).filter((w) => !FREE.has(w.toLowerCase().replace(/[^\p{L}]/gu, '')));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const ABBREVIATIONS = /\b(?:e\.g|i\.e|vs|etc)\.$/i;
|
|
224
|
+
|
|
225
|
+
export function sentences(text: string): number {
|
|
226
|
+
let count = 1;
|
|
227
|
+
const boundary = /[.!?…]+["')\]]*\s+(?=\S)/g;
|
|
228
|
+
for (let m = boundary.exec(text); m !== null; m = boundary.exec(text))
|
|
229
|
+
if (!ABBREVIATIONS.test(text.slice(0, m.index + m[0].trimEnd().length))) count += 1;
|
|
230
|
+
|
|
231
|
+
return count;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A word or phrase on its own, not inside a longer word or a hyphenated compound. */
|
|
235
|
+
export function phrase(p: string): RegExp {
|
|
236
|
+
const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+');
|
|
237
|
+
return new RegExp(`(?<![\\p{L}\\p{N}'-])${escaped}(?![\\p{L}\\p{N}'-])`, 'iu');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The words and phrases of `list` that some reading carries. */
|
|
241
|
+
const listed = (variants: string[], list: string[]) => list.filter((w) => variants.some((v) => phrase(w).test(v)));
|
|
242
|
+
|
|
243
|
+
const unquoted = (token: string) => token.replace(/^[("'“‘]+|[)"'”’.,:;!?]+$/g, '');
|
|
244
|
+
|
|
245
|
+
function capitalizedWord(text: string, proper: Set<string>): string | undefined {
|
|
246
|
+
const parts = text.split(/\s+/).filter(Boolean);
|
|
247
|
+
for (let i = 1; i < parts.length; i++) {
|
|
248
|
+
const word = unquoted(parts[i]!);
|
|
249
|
+
if (!/^\p{Lu}\p{Ll}/u.test(word) || proper.has(word)) continue;
|
|
250
|
+
if (/[.:?!]["')]*$/.test(parts[i - 1]!)) continue;
|
|
251
|
+
return word;
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Words a count is followed by that are not plural nouns, though they end in s. */
|
|
257
|
+
const NOT_PLURAL = new Set([
|
|
258
|
+
'is',
|
|
259
|
+
'was',
|
|
260
|
+
'has',
|
|
261
|
+
'does',
|
|
262
|
+
'this',
|
|
263
|
+
'its',
|
|
264
|
+
'plus',
|
|
265
|
+
'minus',
|
|
266
|
+
'less',
|
|
267
|
+
'yes',
|
|
268
|
+
'us',
|
|
269
|
+
'as',
|
|
270
|
+
'bus',
|
|
271
|
+
'gas',
|
|
272
|
+
'news',
|
|
273
|
+
'series',
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
/** The singular of an English plural noun, or nothing where it doesn't look like one. */
|
|
277
|
+
export function singularOf(word: string): string | undefined {
|
|
278
|
+
const lower = word.toLowerCase();
|
|
279
|
+
if (word.length < 3 || !lower.endsWith('s') || lower.endsWith('ss') || NOT_PLURAL.has(lower)) return undefined;
|
|
280
|
+
if (/[^aeiou]ies$/.test(lower)) return `${word.slice(0, -3)}y`;
|
|
281
|
+
if (/(?:s|x|z|ch|sh)es$/.test(lower)) return word.slice(0, -2);
|
|
282
|
+
return word.slice(0, -1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const MINOR = new Set('a an the and but or nor for so yet as at by in of off on per to up via with'.split(' '));
|
|
286
|
+
|
|
287
|
+
/** The first word out of `style` case, less those in `keep`. */
|
|
288
|
+
function outOfCase(text: string, style: CapitalizationRule['case'], keep: Set<string>): string | undefined {
|
|
289
|
+
if (style === 'sentence') return capitalizedWord(text, keep);
|
|
290
|
+
const list = text
|
|
291
|
+
.split(/\s+/)
|
|
292
|
+
.map(unquoted)
|
|
293
|
+
.filter((w) => /\p{L}/u.test(w) && !keep.has(w));
|
|
294
|
+
return list.find((w, i) => {
|
|
295
|
+
if (style === 'lower') return /\p{Lu}/u.test(w);
|
|
296
|
+
if (style === 'upper') return /\p{Ll}/u.test(w);
|
|
297
|
+
return /^\p{Ll}/u.test(w) && (i === 0 || !MINOR.has(w));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const CONTRACTIONS: [RegExp, string][] = [
|
|
302
|
+
[/\bcannot\b/i, "can't"],
|
|
303
|
+
[/\bcan not\b/i, "can't"],
|
|
304
|
+
[/\bwill not\b/i, "won't"],
|
|
305
|
+
[/\b(could|would|should|is|are|was|were|do|does|did|has|have|had|must|need) not\b/i, "n't"],
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
/** Calls `visit` on every run of elements that sit side by side, with the arguments a plural around it counts. */
|
|
309
|
+
function eachRun(
|
|
310
|
+
elements: MessageFormatElement[],
|
|
311
|
+
visit: (run: MessageFormatElement[], counted: ReadonlySet<string>) => void,
|
|
312
|
+
counted: ReadonlySet<string> = new Set(),
|
|
313
|
+
): void {
|
|
314
|
+
visit(elements, counted);
|
|
315
|
+
for (const el of elements) {
|
|
316
|
+
if (el.type === TYPE.plural) for (const o of Object.values(el.options)) eachRun(o.value, visit, new Set([...counted, el.value]));
|
|
317
|
+
if (el.type === TYPE.select) for (const o of Object.values(el.options)) eachRun(o.value, visit, counted);
|
|
318
|
+
if (el.type === TYPE.tag) eachRun(el.children, visit, counted);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A plain placeholder named as a count. A number placeholder is one whatever its name. */
|
|
323
|
+
const COUNT_NAME = /^(?:n|num|count|total|amount|quantity)$|(?:count|total)$/i;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Whether an arm ends with a stop; nothing for an arm with no words. Which stop is the arm's own: a
|
|
327
|
+
* question in `one` and a second sentence after it in `other` both end well.
|
|
328
|
+
*/
|
|
329
|
+
function endsWithStop(arm: MessageFormatElement[]): boolean | undefined {
|
|
330
|
+
const text = prose(renderAs(arm, 'other')).trim();
|
|
331
|
+
return text.replaceAll(HOLE, '').trim() === '' ? undefined : hasStop(text);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Ends with a stop in any script (`。`, `؟`, `;` as Greek writes `?`), though not a comma. */
|
|
335
|
+
const hasStop = (text: string) => /(?![,,、،])[\p{Terminal_Punctuation}…]["')”»」』]*$/u.test(prose(text).trim());
|
|
336
|
+
|
|
337
|
+
const CURRENCY_BEFORE = /[$€£¥₹]\s?$/;
|
|
338
|
+
|
|
339
|
+
const OFF_SCREEN = new Set(['aria']);
|
|
340
|
+
|
|
341
|
+
const an = (word = 'string') => `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${word}`;
|
|
342
|
+
|
|
343
|
+
export const CORE_RULES = {
|
|
344
|
+
whitespace: {
|
|
345
|
+
description: 'No leading, trailing or doubled spaces.',
|
|
346
|
+
recommended: 'error',
|
|
347
|
+
on: { locale: 'all' },
|
|
348
|
+
check: ({ text }) => {
|
|
349
|
+
const message = 'no leading, trailing or doubled spaces';
|
|
350
|
+
// As a match, two spaces stand for a run of any length.
|
|
351
|
+
if (text.variants.some((v) => v.includes(' '))) return [{ message, match: ' ', fix: ' ' }];
|
|
352
|
+
return text.variants.some((v) => /^\s|\s$/.test(v.replaceAll(HOLE, 'x'))) ? [message] : [];
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
'word-budget': {
|
|
356
|
+
description: "A kind with `maxWords` stays within it. Articles, short prepositions and asides in parentheses don't count.",
|
|
357
|
+
recommended: 'warn',
|
|
358
|
+
check: ({ message: m, kind, text }) => {
|
|
359
|
+
if (kind?.maxWords === undefined || OFF_SCREEN.has(m.surface ?? '')) return [];
|
|
360
|
+
const n = words(text.primary).length;
|
|
361
|
+
return n > kind.maxWords ? [`${an(m.kind)} is ${kind.maxWords} words at most, and this is ${n}`] : [];
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
'sentence-budget': {
|
|
365
|
+
description: 'A kind with `maxSentences` stays within it.',
|
|
366
|
+
recommended: 'warn',
|
|
367
|
+
check: ({ message: m, kind, text }) => {
|
|
368
|
+
const most = (m.surface !== undefined ? kind?.maxSentencesOn?.[m.surface] : undefined) ?? kind?.maxSentences;
|
|
369
|
+
if (most === undefined) return [];
|
|
370
|
+
const n = sentences(prose(text.primary));
|
|
371
|
+
return n > most
|
|
372
|
+
? [
|
|
373
|
+
`${an(m.kind)}${kind?.maxSentencesOn?.[m.surface ?? ''] !== undefined ? ` on ${m.surface}` : ''} is ${most} sentence(s) at most, and this is ${n}`,
|
|
374
|
+
]
|
|
375
|
+
: [];
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
period: {
|
|
379
|
+
description: 'A kind with `period: always` (or on a listed surface) ends with a period, unless the entry is a fragment.',
|
|
380
|
+
recommended: 'warn',
|
|
381
|
+
check: ({ message: m, kind, text }) => {
|
|
382
|
+
const rule = kind?.period;
|
|
383
|
+
const applies = rule === 'always' || (Array.isArray(rule) && rule.includes(m.surface ?? ''));
|
|
384
|
+
return applies && !m.fragment && !/[.?!…:]["')”]*$/.test(prose(text.primary))
|
|
385
|
+
? [`${an(m.kind)} ends with a period, or is marked fragment: true`]
|
|
386
|
+
: [];
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
'no-period': {
|
|
390
|
+
description: 'A kind with `period: never` ends without one.',
|
|
391
|
+
recommended: 'warn',
|
|
392
|
+
check: ({ message: m, kind, text }) =>
|
|
393
|
+
kind?.period === 'never' && /[^.]\.$/.test(prose(text.primary)) ? [`${an(m.kind)} ends without a period`] : [],
|
|
394
|
+
},
|
|
395
|
+
'bare-name': {
|
|
396
|
+
description: 'A kind with `bare: true` carries no explanation after a dash or a colon.',
|
|
397
|
+
recommended: 'warn',
|
|
398
|
+
// A colon before a value is a readout, as in "Zoom: 120%", not an explanation.
|
|
399
|
+
check: ({ message: m, kind, text }) =>
|
|
400
|
+
kind?.bare === true && new RegExp(`\\s[—–-]\\s|\\p{L}:\\s(?!${HOLE})`, 'u').test(text.skeleton)
|
|
401
|
+
? [`${an(m.kind)} carries no explanation after a dash or a colon`]
|
|
402
|
+
: [],
|
|
403
|
+
},
|
|
404
|
+
'sentence-case': {
|
|
405
|
+
description: 'A kind with `sentenceCase: true` capitalizes only its first word and proper nouns.',
|
|
406
|
+
check: ({ message: m, kind, text, config }) => {
|
|
407
|
+
if (kind?.sentenceCase !== true) return [];
|
|
408
|
+
const word = outOfCase(text.primary, 'sentence', new Set(config.lint.properNouns));
|
|
409
|
+
if (word === undefined) return [];
|
|
410
|
+
return [
|
|
411
|
+
{ message: `"${word}" is capitalized in a sentence-case ${m.kind}: add it to lint.properNouns if it is a name`, match: word },
|
|
412
|
+
];
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
'banned-word': {
|
|
416
|
+
description: 'A word or phrase from the banned lists.',
|
|
417
|
+
recommended: 'warn',
|
|
418
|
+
check: ({ text, config }) =>
|
|
419
|
+
config.lint.banned.flatMap(({ words: list, why }) =>
|
|
420
|
+
listed(text.variants, list).map((w) => ({ message: `"${w}" ${why}`, match: w })),
|
|
421
|
+
),
|
|
422
|
+
},
|
|
423
|
+
spelling: {
|
|
424
|
+
description: 'A spelling the product writes another way.',
|
|
425
|
+
recommended: 'warn',
|
|
426
|
+
check: ({ text, config: { lint } }) =>
|
|
427
|
+
listed(text.variants, Object.keys(lint.spelling)).map((w) => ({
|
|
428
|
+
message: `"${w}" is spelled "${lint.spelling[w]}"`,
|
|
429
|
+
match: w,
|
|
430
|
+
fix: lint.spelling[w],
|
|
431
|
+
})),
|
|
432
|
+
},
|
|
433
|
+
'one-word': {
|
|
434
|
+
description: 'A near-synonym for a concept the product has one word for.',
|
|
435
|
+
recommended: 'warn',
|
|
436
|
+
check: ({ text, config: { lint } }) =>
|
|
437
|
+
listed(text.variants, Object.keys(lint.concepts)).map((w) => ({
|
|
438
|
+
message: `"${w}" is "${lint.concepts[w]}" everywhere in the product`,
|
|
439
|
+
match: w,
|
|
440
|
+
fix: lint.concepts[w],
|
|
441
|
+
})),
|
|
442
|
+
},
|
|
443
|
+
'no-em-dash': {
|
|
444
|
+
description: 'No em-dashes, and no spaced hyphen standing in for one.',
|
|
445
|
+
check: ({ text }) => {
|
|
446
|
+
const match = text.variants.map((v) => /—|\s[–-]\s/.exec(v)?.[0]).find((m) => m !== undefined);
|
|
447
|
+
return match === undefined ? [] : [{ message: 'no em-dash: a comma, a period or a colon does the job', match }];
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
contractions: {
|
|
451
|
+
description: '`could not`, `is not` and `cannot` are written as contractions.',
|
|
452
|
+
check: ({ text }) =>
|
|
453
|
+
CONTRACTIONS.flatMap(([pattern, short]) => {
|
|
454
|
+
const found = text.variants.map((v) => pattern.exec(v)).find((f) => f !== null);
|
|
455
|
+
if (found === undefined || found === null) return [];
|
|
456
|
+
const fix = short === "n't" ? `${found[1]}n't` : short;
|
|
457
|
+
return [{ message: `"${found[0]}" is written as a contraction (${short})`, match: found[0], fix }];
|
|
458
|
+
}),
|
|
459
|
+
},
|
|
460
|
+
ellipsis: {
|
|
461
|
+
description: 'An ellipsis is one character, `…`, not three periods.',
|
|
462
|
+
on: { locale: 'all' },
|
|
463
|
+
check: ({ text }) =>
|
|
464
|
+
text.variants.some((v) => v.includes('...')) ? [{ message: 'an ellipsis is one character: …', match: '...', fix: '…' }] : [],
|
|
465
|
+
},
|
|
466
|
+
'plural-count': {
|
|
467
|
+
description: 'A count followed by a plural noun, `{count} files`, is a plural, or it reads "1 files". English.',
|
|
468
|
+
recommended: 'warn',
|
|
469
|
+
check: ({ ast }) => {
|
|
470
|
+
const said: Report[] = [];
|
|
471
|
+
eachRun(ast, (run, counted) =>
|
|
472
|
+
run.forEach((el, i) => {
|
|
473
|
+
if (el.type !== TYPE.number && el.type !== TYPE.argument) return;
|
|
474
|
+
if (counted.has(el.value) || (el.type === TYPE.argument && !COUNT_NAME.test(el.value))) return;
|
|
475
|
+
const next = run[i + 1];
|
|
476
|
+
const word = next?.type === TYPE.literal ? /^\s+(\p{L}+)(?![\p{L}'])/u.exec(next.value)?.[1] : undefined;
|
|
477
|
+
const one = word === undefined ? undefined : singularOf(word);
|
|
478
|
+
if (word !== undefined && one !== undefined) {
|
|
479
|
+
said.push({
|
|
480
|
+
message: `${el.value} is followed by "${word}", which reads "1 ${word}": make it a plural with a "# ${one}" arm`,
|
|
481
|
+
match: word,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
return said;
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
'plural-one': {
|
|
490
|
+
description: "A plural's `one` arm says something other than `other`, and says `#` rather than `1`.",
|
|
491
|
+
recommended: 'warn',
|
|
492
|
+
on: { locale: 'all' },
|
|
493
|
+
check: ({ ast, locale, config }) => {
|
|
494
|
+
const said: Report[] = [];
|
|
495
|
+
walk(ast, (el) => {
|
|
496
|
+
if (el.type !== TYPE.plural || el.pluralType !== 'cardinal') return;
|
|
497
|
+
const { one, other } = el.options;
|
|
498
|
+
if (one === undefined) return;
|
|
499
|
+
// Turkish says "# dosya" for one and other alike, so only the source must tell them apart.
|
|
500
|
+
if (locale === config.source && other !== undefined && renderAs(one.value, 'other') === renderAs(other.value, 'other'))
|
|
501
|
+
said.push({ message: `the one and other arms of ${el.value} say the same: write the singular in one` });
|
|
502
|
+
let literalOne = false;
|
|
503
|
+
walk(one.value, (inner) => {
|
|
504
|
+
if (inner.type === TYPE.literal && /(?<![\d.,])1(?![\d.,])/.test(inner.value)) literalOne = true;
|
|
505
|
+
});
|
|
506
|
+
// Russian's `one` is also 21 and 31, so a translator copying a written 1 gets it wrong there.
|
|
507
|
+
if (!literalOne) return;
|
|
508
|
+
const message = `the one arm of ${el.value} writes 1, which one is not in every language: write #, or make the arm =1`;
|
|
509
|
+
said.push({ message, match: '1' });
|
|
510
|
+
});
|
|
511
|
+
return said;
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
'arm-endings': {
|
|
515
|
+
description: 'The arms of a plural or select end alike: all with a stop, or all without one.',
|
|
516
|
+
recommended: 'warn',
|
|
517
|
+
on: { locale: 'all' },
|
|
518
|
+
check: ({ ast }) => {
|
|
519
|
+
const said: string[] = [];
|
|
520
|
+
walk(ast, (el) => {
|
|
521
|
+
if (el.type !== TYPE.plural && el.type !== TYPE.select) return;
|
|
522
|
+
const endings = new Set(Object.values(el.options).flatMap((o) => endsWithStop(o.value) ?? []));
|
|
523
|
+
if (endings.size > 1) said.push(`some arms of ${el.value} end with a stop and some without: end them alike`);
|
|
524
|
+
});
|
|
525
|
+
return said;
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
'formatted-by-hand': {
|
|
529
|
+
description: 'A currency symbol or `%` typed beside a placeholder, or digits grouped by hand, which only suit one locale.',
|
|
530
|
+
recommended: 'warn',
|
|
531
|
+
on: { locale: 'all' },
|
|
532
|
+
check: ({ ast, text }) => {
|
|
533
|
+
const said = new Map<string, Report>();
|
|
534
|
+
const say = (message: string, match: string) => said.set(message, { message, match });
|
|
535
|
+
eachRun(ast, (run) =>
|
|
536
|
+
run.forEach((el, i) => {
|
|
537
|
+
if (el.type !== TYPE.number && el.type !== TYPE.argument) return;
|
|
538
|
+
const before = run[i - 1];
|
|
539
|
+
const after = run[i + 1];
|
|
540
|
+
const symbol = before?.type === TYPE.literal ? CURRENCY_BEFORE.exec(before.value)?.[0].trim() : undefined;
|
|
541
|
+
if (symbol !== undefined)
|
|
542
|
+
say(`"${symbol}" is typed before ${el.value}: format it as a currency, which puts the symbol where the locale does`, symbol);
|
|
543
|
+
if (after?.type === TYPE.literal && /^\s?%/.test(after.value))
|
|
544
|
+
say(`"%" is typed after ${el.value}: format it as a percent, which takes the fraction (0.5 for 50%)`, '%');
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
const grouped = text.variants.map((v) => /\d{1,3}(?:,\d{3})+(?!\d)/.exec(v)?.[0]).find((g) => g !== undefined);
|
|
548
|
+
if (grouped !== undefined)
|
|
549
|
+
say(`"${grouped}" groups its digits the English way: make it a placeholder formatted as a number`, grouped);
|
|
550
|
+
return [...said.values()];
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
'source-ending': {
|
|
554
|
+
description: 'A translation ends with a stop where its source does, and without one where it does not.',
|
|
555
|
+
recommended: 'warn',
|
|
556
|
+
on: { locale: 'all' },
|
|
557
|
+
check: ({ text, source }) => {
|
|
558
|
+
const ends = hasStop(text.primary);
|
|
559
|
+
if (ends === hasStop(source.primary)) return [];
|
|
560
|
+
return [
|
|
561
|
+
ends
|
|
562
|
+
? 'ends with a stop, and the source does not: drop it'
|
|
563
|
+
: 'the source ends with a stop, and this does not: end it as the source does',
|
|
564
|
+
];
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
} satisfies Record<string, CheckRule>;
|
|
568
|
+
|
|
569
|
+
export type CoreRule = keyof typeof CORE_RULES;
|
|
570
|
+
|
|
571
|
+
const fill = (message: string, values: Record<string, string | number | undefined>) =>
|
|
572
|
+
message.replace(/\{(\w+)\}/g, (all, name: string) => (values[name] === undefined ? all : String(values[name])));
|
|
573
|
+
|
|
574
|
+
const graphemes = new Intl.Segmenter();
|
|
575
|
+
|
|
576
|
+
const COUNTERS: Record<OccurrenceRule['count'], (text: string) => number> = {
|
|
577
|
+
words: (text) => tokens(text).length,
|
|
578
|
+
sentences: (text) => sentences(prose(text)),
|
|
579
|
+
characters: (text) => [...graphemes.segment(text.replaceAll(HOLE, ''))].length,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/** A rule as code, checked once when the config loads: a JSON config is not type-checked. */
|
|
583
|
+
export function compileRule(name: string, rule: Rule): CheckRule {
|
|
584
|
+
const fail = (problem: string): never => {
|
|
585
|
+
throw new Error(`lint rule ${name} ${problem}`);
|
|
586
|
+
};
|
|
587
|
+
const pattern = (source: unknown, field: string, ignoreCase: boolean | undefined) => {
|
|
588
|
+
if (typeof source !== 'string') return fail(`${field} is not a string: write the pattern as one`);
|
|
589
|
+
try {
|
|
590
|
+
return new RegExp(source, ignoreCase === true ? 'iu' : 'u');
|
|
591
|
+
} catch (e) {
|
|
592
|
+
return fail(`${field} is not a regular expression: ${e instanceof Error ? e.message : String(e)}`);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
const said = (message: unknown) =>
|
|
596
|
+
typeof message === 'string' && message !== '' ? message : fail('has no message: say what to write instead');
|
|
597
|
+
if (typeof rule.description !== 'string' || rule.description === '') fail('has no description: say what it holds a string to');
|
|
598
|
+
if ('check' in rule) return typeof rule.check === 'function' ? rule : fail('has a check that is not a function');
|
|
599
|
+
const { description, recommended, strict, on } = rule;
|
|
600
|
+
const base = { description, recommended, strict, on };
|
|
601
|
+
switch (rule.type) {
|
|
602
|
+
case 'existence': {
|
|
603
|
+
const list = rule.words ?? [];
|
|
604
|
+
const patterns = (rule.patterns ?? []).map((p, i) => pattern(p, `patterns[${i}]`, rule.ignoreCase));
|
|
605
|
+
if (list.length + patterns.length === 0) fail('has no words or patterns: list what a string never says');
|
|
606
|
+
const message = said(rule.message);
|
|
607
|
+
return {
|
|
608
|
+
...base,
|
|
609
|
+
check: ({ text }) => {
|
|
610
|
+
const matched = patterns.flatMap((p) => text.variants.map((v) => p.exec(v)?.[0]).find((m) => m !== undefined) ?? []);
|
|
611
|
+
return [...new Set([...listed(text.variants, list), ...matched])].map((match) => ({ message: fill(message, { match }), match }));
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
case 'substitution': {
|
|
616
|
+
const swap = rule.swap ?? {};
|
|
617
|
+
if (Object.keys(swap).length === 0) fail('has nothing in swap: map a word to the one written instead');
|
|
618
|
+
const message = rule.message === undefined ? '"{match}" is written "{use}"' : said(rule.message);
|
|
619
|
+
return {
|
|
620
|
+
...base,
|
|
621
|
+
check: ({ text }) =>
|
|
622
|
+
listed(text.variants, Object.keys(swap)).map((match) => ({
|
|
623
|
+
message: fill(message, { match, use: swap[match] }),
|
|
624
|
+
match,
|
|
625
|
+
fix: swap[match],
|
|
626
|
+
})),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
case 'occurrence': {
|
|
630
|
+
const { count: unit, max, min, relative } = rule;
|
|
631
|
+
const counter = Object.hasOwn(COUNTERS, unit) ? COUNTERS[unit] : fail(`counts ${unit}: count words, sentences or characters`);
|
|
632
|
+
if (max === undefined && min === undefined) fail('has no max or min: give it one');
|
|
633
|
+
if (rule.message !== undefined) said(rule.message);
|
|
634
|
+
return {
|
|
635
|
+
...base,
|
|
636
|
+
check: ({ text, source }) => {
|
|
637
|
+
const count = counter(text.primary);
|
|
638
|
+
const of = relative === true ? counter(source.primary) : 1;
|
|
639
|
+
const over = max !== undefined && count > max * of;
|
|
640
|
+
const under = min !== undefined && count < min * of;
|
|
641
|
+
if (!over && !under) return [];
|
|
642
|
+
// The limit shown is one the count could meet: 1.5 times 11 characters is 16 at most, 17 at least.
|
|
643
|
+
const limit = over && max !== undefined ? Math.floor(max * of) : Math.ceil((min ?? 0) * of);
|
|
644
|
+
const bound = over ? 'at most' : 'at least';
|
|
645
|
+
const fallback =
|
|
646
|
+
relative === true
|
|
647
|
+
? `{limit} ${unit} ${bound} ({${over ? 'max' : 'min'}} times the source's {source}), and this is {count}`
|
|
648
|
+
: `{${over ? 'max' : 'min'}} ${unit} ${bound}, and this is {count}`;
|
|
649
|
+
return [fill(rule.message ?? fallback, { count, max, min, source: relative === true ? of : undefined, limit })];
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
case 'capitalization': {
|
|
654
|
+
const style = rule.case;
|
|
655
|
+
if (!['sentence', 'title', 'lower', 'upper'].includes(style)) fail(`has case ${style}: use sentence, title, lower or upper`);
|
|
656
|
+
const message =
|
|
657
|
+
rule.message === undefined
|
|
658
|
+
? `"{match}" is out of ${style} case: change it, or add it to exceptions if it is a name`
|
|
659
|
+
: said(rule.message);
|
|
660
|
+
const exceptions = rule.exceptions ?? [];
|
|
661
|
+
return {
|
|
662
|
+
...base,
|
|
663
|
+
check: ({ text, config }) => {
|
|
664
|
+
const match = outOfCase(text.primary, style, new Set([...config.lint.properNouns, ...exceptions]));
|
|
665
|
+
return match === undefined ? [] : [{ message: fill(message, { match }), match }];
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
case 'conditional': {
|
|
670
|
+
const when = rule.when === undefined ? undefined : pattern(rule.when, 'when', rule.ignoreCase);
|
|
671
|
+
const required = pattern(rule.require, 'require', rule.ignoreCase);
|
|
672
|
+
const message = said(rule.message);
|
|
673
|
+
return {
|
|
674
|
+
...base,
|
|
675
|
+
check: ({ text }) => {
|
|
676
|
+
for (const v of text.variants) {
|
|
677
|
+
const match = when === undefined ? '' : when.exec(v)?.[0];
|
|
678
|
+
if (match !== undefined && !required.test(v)) return [fill(message, { match })];
|
|
679
|
+
}
|
|
680
|
+
return [];
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
default:
|
|
685
|
+
return fail(
|
|
686
|
+
`has type ${String((rule as { type?: unknown }).type)}: use existence, substitution, occurrence, capitalization or conditional, or give it a check`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** A reason is a sentence a reviewer can check, not a shrug. */
|
|
692
|
+
function reasonProblem(why: string): string | undefined {
|
|
693
|
+
if (why.split(/\s+/).length < 4) return 'is four words at least: say why the rule is wrong here';
|
|
694
|
+
if (/^(?:intentional|needed|required|ok|fine|false positive|by design|legacy|todo)\b/i.test(why))
|
|
695
|
+
return 'names the reason, not that there is one';
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** A locale is one a rule reads when it names its language, or the locale and a region of it. */
|
|
700
|
+
const readsLocale = (locales: 'all' | string[] | undefined, locale: string, sourceLocale: string) =>
|
|
701
|
+
locales === undefined
|
|
702
|
+
? locale === sourceLocale
|
|
703
|
+
: locales === 'all' ||
|
|
704
|
+
locales.some((l) => locale.toLowerCase() === l.toLowerCase() || locale.toLowerCase().startsWith(`${l.toLowerCase()}-`));
|
|
705
|
+
|
|
706
|
+
function applies(rule: RuleBase, message: Message, locale: string, sourceLocale: string): boolean {
|
|
707
|
+
const { kind, surface, locale: locales } = rule.on ?? {};
|
|
708
|
+
return (
|
|
709
|
+
readsLocale(locales, locale, sourceLocale) &&
|
|
710
|
+
(kind === undefined || (message.kind !== undefined && kind.includes(message.kind))) &&
|
|
711
|
+
(surface === undefined || (message.surface !== undefined && surface.includes(message.surface)))
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
interface Literal {
|
|
716
|
+
start: number;
|
|
717
|
+
value: string;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Where the message's literals are written in its text, each looked for after the one before. A
|
|
722
|
+
* literal escaped in the text (`''`, `\{`) isn't written as read, so it has no place, and nor does a
|
|
723
|
+
* finding in it.
|
|
724
|
+
*/
|
|
725
|
+
function literalsIn(ast: MessageFormatElement[], text: string): Literal[] {
|
|
726
|
+
const found: Literal[] = [];
|
|
727
|
+
let from = 0;
|
|
728
|
+
walk(ast, (el) => {
|
|
729
|
+
if (el.type !== TYPE.literal || el.value === '') return;
|
|
730
|
+
// MF2's `.match` is read into arms in another order than they are written, so look from the top too.
|
|
731
|
+
const next = text.indexOf(el.value, from);
|
|
732
|
+
const start = next === -1 ? text.indexOf(el.value) : next;
|
|
733
|
+
if (start === -1) return;
|
|
734
|
+
found.push({ start, value: el.value });
|
|
735
|
+
from = start + el.value.length;
|
|
736
|
+
});
|
|
737
|
+
return found;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Every place `match` stands in the literals, in any case, whole where it starts or ends with a letter. */
|
|
741
|
+
function placesOf(match: string, literals: Literal[]): { start: number; end: number }[] {
|
|
742
|
+
const body = match
|
|
743
|
+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
744
|
+
.replace(/ /g, '\\s+')
|
|
745
|
+
.replace(/'/g, "['’]");
|
|
746
|
+
const before = /^[\p{L}\p{N}]/u.test(match) ? '(?<![\\p{L}\\p{N}])' : '';
|
|
747
|
+
const after = /[\p{L}\p{N}]$/u.test(match) ? '(?![\\p{L}\\p{N}])' : '';
|
|
748
|
+
const pattern = new RegExp(`${before}${body}${after}`, 'giu');
|
|
749
|
+
const places = new Map<number, number>();
|
|
750
|
+
for (const { start, value } of literals)
|
|
751
|
+
for (const m of value.matchAll(pattern)) places.set(start + m.index, start + m.index + m[0].length);
|
|
752
|
+
return [...places].sort(([a], [b]) => a - b).map(([start, end]) => ({ start, end }));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** A fix in the case of what it replaces: "Colour" becomes "Color". */
|
|
756
|
+
const inCaseOf = (found: string, fix: string) =>
|
|
757
|
+
/^\p{Lu}/u.test(found) && /^\p{Ll}/u.test(fix) ? fix.charAt(0).toUpperCase() + fix.slice(1) : fix;
|
|
758
|
+
|
|
759
|
+
/** `text` with the edits made, leaving out any that overlaps one before it. */
|
|
760
|
+
export function applyEdits(text: string, edits: Edit[]): string {
|
|
761
|
+
let out = '';
|
|
762
|
+
let at = 0;
|
|
763
|
+
for (const edit of [...edits].sort((a, b) => a.start - b.start)) {
|
|
764
|
+
if (edit.start < at) continue;
|
|
765
|
+
out += text.slice(at, edit.start) + edit.text;
|
|
766
|
+
at = edit.end;
|
|
767
|
+
}
|
|
768
|
+
return out + text.slice(at);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
|
|
772
|
+
const findings: Finding[] = [];
|
|
773
|
+
const rules = Object.entries(config.lint.rules);
|
|
774
|
+
for (const message of messages) {
|
|
775
|
+
const kind = message.kind === undefined ? undefined : config.kinds[message.kind];
|
|
776
|
+
const source = readings(message.ast);
|
|
777
|
+
const locales = [
|
|
778
|
+
{ locale: config.source, ast: message.ast, written: message.text, file: message.file },
|
|
779
|
+
...[...message.translations].map(([locale, t]) => ({ locale, ast: t.ast, written: t.text, file: t.file })),
|
|
780
|
+
];
|
|
781
|
+
// An exemption is the entry's, so it holds in every locale, and is still needed if any locale needs it.
|
|
782
|
+
const fired = new Set<string>();
|
|
783
|
+
for (const { locale, ast, written, file } of locales) {
|
|
784
|
+
const text = locale === config.source ? source : readings(ast);
|
|
785
|
+
const context: RuleContext = { message, locale, ast, kind, text, source, config };
|
|
786
|
+
let literals: Literal[] | undefined;
|
|
787
|
+
for (const [name, { rule, severity }] of rules) {
|
|
788
|
+
if (severity === 'off' || !applies(rule, message, locale, config.source)) continue;
|
|
789
|
+
let said: (string | Report)[];
|
|
790
|
+
try {
|
|
791
|
+
said = rule.check(context);
|
|
792
|
+
} catch (cause) {
|
|
793
|
+
throw new Error(`lint rule ${name} failed on ${message.key} in ${locale}: fix or turn off the rule`, { cause });
|
|
794
|
+
}
|
|
795
|
+
if (said.length === 0) continue;
|
|
796
|
+
fired.add(name);
|
|
797
|
+
if (message.allow.has(name)) continue;
|
|
798
|
+
for (const s of said) {
|
|
799
|
+
const { message: text, match, fix } = typeof s === 'string' ? { message: s } : s;
|
|
800
|
+
const finding: Finding = { key: message.key, file, locale, rule: name, severity, message: text };
|
|
801
|
+
if (match !== undefined && match !== '') {
|
|
802
|
+
literals ??= literalsIn(ast, written);
|
|
803
|
+
const places = placesOf(match, literals);
|
|
804
|
+
if (places.length > 0) finding.at = places[0];
|
|
805
|
+
if (places.length > 0 && fix !== undefined)
|
|
806
|
+
finding.fix = places.map((p) => ({ ...p, text: inCaseOf(written.slice(p.start, p.end), fix) }));
|
|
807
|
+
}
|
|
808
|
+
findings.push(finding);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const [name, why] of message.allow) {
|
|
813
|
+
const at = { key: message.key, file: message.file, locale: config.source, rule: 'allow', severity: 'error' as const };
|
|
814
|
+
const rule = config.lint.rules[name];
|
|
815
|
+
if (rule === undefined) {
|
|
816
|
+
findings.push({ ...at, message: `allow.${name} is not a rule: ${rules.map(([n]) => n).join(', ')}` });
|
|
817
|
+
} else if (rule.severity === 'off') {
|
|
818
|
+
continue;
|
|
819
|
+
} else if (!fired.has(name)) {
|
|
820
|
+
findings.push({ ...at, message: `allow.${name} exempts the entry from a rule it passes: remove it` });
|
|
821
|
+
} else {
|
|
822
|
+
const problem = reasonProblem(why);
|
|
823
|
+
if (problem !== undefined) findings.push({ ...at, message: `allow.${name} ${problem}` });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return findings;
|
|
828
|
+
}
|