@mark1russell7/ste-lint 0.0.0-bootstrap.0 → 0.1.1
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/README.md +224 -1
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +103 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +251 -0
- package/dist/config.js.map +1 -0
- package/dist/dictionary.d.ts +74 -0
- package/dist/dictionary.d.ts.map +1 -0
- package/dist/dictionary.js +169 -0
- package/dist/dictionary.js.map +1 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +181 -0
- package/dist/files.js.map +1 -0
- package/dist/format.d.ts +21 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +61 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/lexicon.d.ts +33 -0
- package/dist/lexicon.d.ts.map +1 -0
- package/dist/lexicon.js +71 -0
- package/dist/lexicon.js.map +1 -0
- package/dist/lint.d.ts +28 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +132 -0
- package/dist/lint.js.map +1 -0
- package/dist/rules/about-quantity.d.ts +3 -0
- package/dist/rules/about-quantity.d.ts.map +1 -0
- package/dist/rules/about-quantity.js +34 -0
- package/dist/rules/about-quantity.js.map +1 -0
- package/dist/rules/index.d.ts +8 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/index.js +35 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/rules/ing-verb.d.ts +9 -0
- package/dist/rules/ing-verb.d.ts.map +1 -0
- package/dist/rules/ing-verb.js +60 -0
- package/dist/rules/ing-verb.js.map +1 -0
- package/dist/rules/latin-abbreviation.d.ts +3 -0
- package/dist/rules/latin-abbreviation.d.ts.map +1 -0
- package/dist/rules/latin-abbreviation.js +48 -0
- package/dist/rules/latin-abbreviation.js.map +1 -0
- package/dist/rules/missing-subject.d.ts +16 -0
- package/dist/rules/missing-subject.d.ts.map +1 -0
- package/dist/rules/missing-subject.js +138 -0
- package/dist/rules/missing-subject.js.map +1 -0
- package/dist/rules/modal-verb.d.ts +3 -0
- package/dist/rules/modal-verb.d.ts.map +1 -0
- package/dist/rules/modal-verb.js +72 -0
- package/dist/rules/modal-verb.js.map +1 -0
- package/dist/rules/noun-cluster.d.ts +17 -0
- package/dist/rules/noun-cluster.d.ts.map +1 -0
- package/dist/rules/noun-cluster.js +83 -0
- package/dist/rules/noun-cluster.js.map +1 -0
- package/dist/rules/paragraph-length.d.ts +3 -0
- package/dist/rules/paragraph-length.d.ts.map +1 -0
- package/dist/rules/paragraph-length.js +19 -0
- package/dist/rules/paragraph-length.js.map +1 -0
- package/dist/rules/passive-instruction.d.ts +15 -0
- package/dist/rules/passive-instruction.d.ts.map +1 -0
- package/dist/rules/passive-instruction.js +38 -0
- package/dist/rules/passive-instruction.js.map +1 -0
- package/dist/rules/pronouns.d.ts +4 -0
- package/dist/rules/pronouns.d.ts.map +1 -0
- package/dist/rules/pronouns.js +57 -0
- package/dist/rules/pronouns.js.map +1 -0
- package/dist/rules/rule.d.ts +46 -0
- package/dist/rules/rule.d.ts.map +1 -0
- package/dist/rules/rule.js +31 -0
- package/dist/rules/rule.js.map +1 -0
- package/dist/rules/semicolon.d.ts +3 -0
- package/dist/rules/semicolon.d.ts.map +1 -0
- package/dist/rules/semicolon.js +20 -0
- package/dist/rules/semicolon.js.map +1 -0
- package/dist/rules/sentence-length.d.ts +3 -0
- package/dist/rules/sentence-length.d.ts.map +1 -0
- package/dist/rules/sentence-length.js +31 -0
- package/dist/rules/sentence-length.js.map +1 -0
- package/dist/rules/unknown-word.d.ts +9 -0
- package/dist/rules/unknown-word.d.ts.map +1 -0
- package/dist/rules/unknown-word.js +49 -0
- package/dist/rules/unknown-word.js.map +1 -0
- package/dist/rules/word-list-matcher.d.ts +25 -0
- package/dist/rules/word-list-matcher.d.ts.map +1 -0
- package/dist/rules/word-list-matcher.js +60 -0
- package/dist/rules/word-list-matcher.js.map +1 -0
- package/dist/rules/word-list.d.ts +3 -0
- package/dist/rules/word-list.d.ts.map +1 -0
- package/dist/rules/word-list.js +26 -0
- package/dist/rules/word-list.js.map +1 -0
- package/dist/run-cli.d.ts +27 -0
- package/dist/run-cli.d.ts.map +1 -0
- package/dist/run-cli.js +176 -0
- package/dist/run-cli.js.map +1 -0
- package/dist/source/blocks.d.ts +55 -0
- package/dist/source/blocks.d.ts.map +1 -0
- package/dist/source/blocks.js +464 -0
- package/dist/source/blocks.js.map +1 -0
- package/dist/source/inline.d.ts +42 -0
- package/dist/source/inline.d.ts.map +1 -0
- package/dist/source/inline.js +396 -0
- package/dist/source/inline.js.map +1 -0
- package/dist/source/jsx.d.ts +18 -0
- package/dist/source/jsx.d.ts.map +1 -0
- package/dist/source/jsx.js +137 -0
- package/dist/source/jsx.js.map +1 -0
- package/dist/source/lines.d.ts +34 -0
- package/dist/source/lines.d.ts.map +1 -0
- package/dist/source/lines.js +83 -0
- package/dist/source/lines.js.map +1 -0
- package/dist/source/markdown.d.ts +23 -0
- package/dist/source/markdown.d.ts.map +1 -0
- package/dist/source/markdown.js +42 -0
- package/dist/source/markdown.js.map +1 -0
- package/dist/source/suppressions.d.ts +38 -0
- package/dist/source/suppressions.d.ts.map +1 -0
- package/dist/source/suppressions.js +69 -0
- package/dist/source/suppressions.js.map +1 -0
- package/dist/source/tsdoc.d.ts +40 -0
- package/dist/source/tsdoc.d.ts.map +1 -0
- package/dist/source/tsdoc.js +176 -0
- package/dist/source/tsdoc.js.map +1 -0
- package/dist/text/grammar.d.ts +91 -0
- package/dist/text/grammar.d.ts.map +1 -0
- package/dist/text/grammar.js +289 -0
- package/dist/text/grammar.js.map +1 -0
- package/dist/text/phrases.d.ts +30 -0
- package/dist/text/phrases.d.ts.map +1 -0
- package/dist/text/phrases.js +65 -0
- package/dist/text/phrases.js.map +1 -0
- package/dist/text/sentences.d.ts +45 -0
- package/dist/text/sentences.d.ts.map +1 -0
- package/dist/text/sentences.js +141 -0
- package/dist/text/sentences.js.map +1 -0
- package/dist/text/tokens.d.ts +35 -0
- package/dist/text/tokens.d.ts.map +1 -0
- package/dist/text/tokens.js +113 -0
- package/dist/text/tokens.js.map +1 -0
- package/dist/text/words.d.ts +58 -0
- package/dist/text/words.d.ts.map +1 -0
- package/dist/text/words.js +159 -0
- package/dist/text/words.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/units.d.ts +47 -0
- package/dist/units.d.ts.map +1 -0
- package/dist/units.js +42 -0
- package/dist/units.js.map +1 -0
- package/dist/word-list.d.ts +34 -0
- package/dist/word-list.d.ts.map +1 -0
- package/dist/word-list.js +90 -0
- package/dist/word-list.js.map +1 -0
- package/package.json +41 -4
- package/src/cli.ts +9 -0
- package/src/config.test.ts +188 -0
- package/src/config.ts +318 -0
- package/src/dictionary.test.ts +82 -0
- package/src/dictionary.ts +177 -0
- package/src/files.test.ts +107 -0
- package/src/files.ts +191 -0
- package/src/format.ts +78 -0
- package/src/index.test.ts +28 -0
- package/src/index.ts +34 -0
- package/src/lexicon.ts +92 -0
- package/src/lint.test.ts +182 -0
- package/src/lint.ts +135 -0
- package/src/rules/about-quantity.ts +33 -0
- package/src/rules/index.ts +40 -0
- package/src/rules/ing-verb.test.ts +48 -0
- package/src/rules/ing-verb.ts +54 -0
- package/src/rules/latin-abbreviation.ts +46 -0
- package/src/rules/missing-subject.test.ts +53 -0
- package/src/rules/missing-subject.ts +129 -0
- package/src/rules/modal-verb.test.ts +45 -0
- package/src/rules/modal-verb.ts +72 -0
- package/src/rules/noun-cluster.test.ts +49 -0
- package/src/rules/noun-cluster.ts +82 -0
- package/src/rules/paragraph-length.test.ts +30 -0
- package/src/rules/paragraph-length.ts +19 -0
- package/src/rules/passive-instruction.test.ts +38 -0
- package/src/rules/passive-instruction.ts +37 -0
- package/src/rules/pronouns.test.ts +38 -0
- package/src/rules/pronouns.ts +54 -0
- package/src/rules/punctuation.test.ts +59 -0
- package/src/rules/rule.ts +73 -0
- package/src/rules/semicolon.ts +19 -0
- package/src/rules/sentence-length.test.ts +52 -0
- package/src/rules/sentence-length.ts +32 -0
- package/src/rules/unknown-word.test.ts +81 -0
- package/src/rules/unknown-word.ts +43 -0
- package/src/rules/word-list-matcher.ts +68 -0
- package/src/rules/word-list.test.ts +110 -0
- package/src/rules/word-list.ts +25 -0
- package/src/run-cli.test.ts +189 -0
- package/src/run-cli.ts +193 -0
- package/src/source/blocks.ts +534 -0
- package/src/source/inline.test.ts +92 -0
- package/src/source/inline.ts +416 -0
- package/src/source/jsx.test.ts +46 -0
- package/src/source/jsx.ts +147 -0
- package/src/source/lines.test.ts +51 -0
- package/src/source/lines.ts +91 -0
- package/src/source/markdown.test.ts +210 -0
- package/src/source/markdown.ts +49 -0
- package/src/source/suppressions.ts +79 -0
- package/src/source/tsdoc.test.ts +137 -0
- package/src/source/tsdoc.ts +189 -0
- package/src/text/grammar.ts +304 -0
- package/src/text/phrases.ts +72 -0
- package/src/text/sentences.ts +148 -0
- package/src/text/text.test.ts +262 -0
- package/src/text/tokens.ts +135 -0
- package/src/text/words.ts +183 -0
- package/src/types.ts +39 -0
- package/src/units.ts +86 -0
- package/src/word-list.ts +128 -0
- package/ste.config.schema.json +87 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline normalization. This step changes the text of a block into "clean"
|
|
3
|
+
* text that the tokenizer can read:
|
|
4
|
+
*
|
|
5
|
+
* - Each code span, `{@link ...}` tag, URL, MDX expression and quoted text
|
|
6
|
+
* becomes one placeholder character. STE Rule 8.6 counts quoted text and
|
|
7
|
+
* text in a different font as one word. The word rules do not examine the
|
|
8
|
+
* text in a placeholder.
|
|
9
|
+
* - Link text stays. Link destinations, images, footnote references, HTML
|
|
10
|
+
* tags, JSX tags and emphasis markers go away.
|
|
11
|
+
* - HTML entities become the characters that they show.
|
|
12
|
+
*
|
|
13
|
+
* Each character of the clean text keeps the file offset of its source
|
|
14
|
+
* character, so findings point to the correct line and column.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { findBacktickRun } from "./blocks.js";
|
|
18
|
+
import type { Line } from "./lines.js";
|
|
19
|
+
|
|
20
|
+
/** The character that holds the place of a code span, link tag, URL or quoted text. */
|
|
21
|
+
export const PLACEHOLDER = "";
|
|
22
|
+
|
|
23
|
+
export type PlaceholderKind = "code" | "link" | "url" | "quote" | "expr";
|
|
24
|
+
|
|
25
|
+
export type Placeholder = {
|
|
26
|
+
readonly kind : PlaceholderKind;
|
|
27
|
+
/** The text in the placeholder, without its delimiters. */
|
|
28
|
+
readonly text : string;
|
|
29
|
+
/** The file offset of the first character. */
|
|
30
|
+
readonly start : number;
|
|
31
|
+
/** The file offset after the last character. */
|
|
32
|
+
readonly end : number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type InlineText = {
|
|
36
|
+
readonly clean : string;
|
|
37
|
+
/** The file offset of each character of `clean`. */
|
|
38
|
+
readonly offsets : readonly number[];
|
|
39
|
+
/** The placeholders, by their index in `clean`. */
|
|
40
|
+
readonly placeholders : ReadonlyMap<number, Placeholder>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type InlineOptions = {
|
|
44
|
+
readonly mdx : boolean;
|
|
45
|
+
readonly tsdoc : boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// A map, not an object literal, so that "&constructor;" does not find an Object property.
|
|
49
|
+
const ENTITIES : ReadonlyMap<string, string> = new Map([
|
|
50
|
+
["amp", "&"], ["lt", "<"], ["gt", ">"], ["quot", "\""], ["apos", "'"], ["nbsp", " "], ["mdash", "—"],
|
|
51
|
+
["ndash", "–"], ["hellip", "…"], ["copy", "©"], ["reg", "®"], ["trade", "™"],
|
|
52
|
+
["times", "×"], ["rarr", "→"], ["larr", "←"], ["harr", "↔"], ["rsquo", "’"],
|
|
53
|
+
["lsquo", "‘"], ["rdquo", "”"], ["ldquo", "“"], ["middot", "·"], ["bull", "•"],
|
|
54
|
+
["deg", "°"], ["micro", "µ"], ["plusmn", "±"], ["le", "≤"], ["ge", "≥"],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** HTML elements whose content is code, so the content becomes one placeholder. */
|
|
58
|
+
const CODE_ELEMENTS = new Set(["code", "kbd", "samp", "var", "tt"]);
|
|
59
|
+
|
|
60
|
+
const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
|
|
61
|
+
const ALPHANUMERIC = /[\p{L}\p{N}]/u;
|
|
62
|
+
|
|
63
|
+
class Builder {
|
|
64
|
+
clean = "";
|
|
65
|
+
readonly offsets : number[] = [];
|
|
66
|
+
readonly placeholders = new Map<number, Placeholder>();
|
|
67
|
+
|
|
68
|
+
constructor(private readonly raw : string, private readonly rawOffsets : readonly number[]) {}
|
|
69
|
+
|
|
70
|
+
char(rawIndex : number) : void {
|
|
71
|
+
this.emit(this.raw[rawIndex]!, rawIndex);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** This method adds `text` to the clean text, with one offset for each UTF-16 unit, also for a character outside the BMP. */
|
|
75
|
+
emit(text : string, rawIndex : number) : void {
|
|
76
|
+
this.clean += text;
|
|
77
|
+
for (let k = 0; k < text.length; k++) this.offsets.push(this.rawOffsets[rawIndex]!);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
placeholder(kind : PlaceholderKind, text : string, rawStart : number, rawEnd : number) : void {
|
|
81
|
+
this.placeholders.set(this.clean.length, {
|
|
82
|
+
kind,
|
|
83
|
+
text,
|
|
84
|
+
start : this.rawOffsets[rawStart]!,
|
|
85
|
+
end : this.rawOffsets[Math.max(rawStart, rawEnd - 1)]! + 1,
|
|
86
|
+
});
|
|
87
|
+
this.emit(PLACEHOLDER, rawStart);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This function normalizes the inline content of a block. Refer to the module comment. */
|
|
92
|
+
export function normalizeInline(lines : readonly Line[], options : InlineOptions) : InlineText {
|
|
93
|
+
let raw = "";
|
|
94
|
+
const rawOffsets : number[] = [];
|
|
95
|
+
lines.forEach((line, n) => {
|
|
96
|
+
if (n > 0) {
|
|
97
|
+
const previous = lines[n - 1]!;
|
|
98
|
+
raw += "\n";
|
|
99
|
+
rawOffsets.push(previous.offset + previous.text.length);
|
|
100
|
+
}
|
|
101
|
+
raw += line.text;
|
|
102
|
+
for (let k = 0; k < line.text.length; k++) rawOffsets.push(line.offset + k);
|
|
103
|
+
});
|
|
104
|
+
const last = lines[lines.length - 1];
|
|
105
|
+
rawOffsets.push(last === undefined ? 0 : last.offset + last.text.length);
|
|
106
|
+
|
|
107
|
+
const builder = new Builder(raw, rawOffsets);
|
|
108
|
+
scan(raw, 0, raw.length, builder, options);
|
|
109
|
+
return { clean : builder.clean, offsets : builder.offsets, placeholders : builder.placeholders };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isAlphanumeric(char : string | undefined) : boolean {
|
|
113
|
+
return char !== undefined && ALPHANUMERIC.test(char);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scan(raw : string, from : number, to : number, out : Builder, options : InlineOptions) : void {
|
|
117
|
+
let i = from;
|
|
118
|
+
while (i < to) {
|
|
119
|
+
const char = raw[i]!;
|
|
120
|
+
const next = raw[i + 1];
|
|
121
|
+
|
|
122
|
+
if (char === "\\" && next !== undefined && i + 1 < to && ASCII_PUNCTUATION.test(next)) {
|
|
123
|
+
out.char(i + 1);
|
|
124
|
+
i += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (char === "`") {
|
|
129
|
+
let run = 1;
|
|
130
|
+
while (raw[i + run] === "`") run++;
|
|
131
|
+
const close = findBacktickRun(raw, i + run, run);
|
|
132
|
+
if (close !== -1 && close + run <= to) {
|
|
133
|
+
out.placeholder("code", raw.slice(i + run, close).trim(), i, close + run);
|
|
134
|
+
i = close + run;
|
|
135
|
+
} else {
|
|
136
|
+
for (let k = 0; k < run; k++) out.char(i + k);
|
|
137
|
+
i += run;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (char === "<") {
|
|
143
|
+
const end = scanAngle(raw, i, to, out);
|
|
144
|
+
if (end !== -1) {
|
|
145
|
+
i = end;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (char === "{") {
|
|
151
|
+
const end = scanBrace(raw, i, to, out, options);
|
|
152
|
+
if (end !== -1) {
|
|
153
|
+
i = end;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (char === "!" && next === "[") {
|
|
159
|
+
const end = linkEnd(raw, i + 1, to);
|
|
160
|
+
if (end !== null) {
|
|
161
|
+
i = end.end;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (char === "[") {
|
|
167
|
+
if (/^\[\^[^\]\s]+\]/.test(raw.slice(i, to))) {
|
|
168
|
+
i = raw.indexOf("]", i) + 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const end = linkEnd(raw, i, to);
|
|
172
|
+
if (end !== null) {
|
|
173
|
+
scan(raw, i + 1, end.textEnd, out, options);
|
|
174
|
+
i = end.end;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if ((char === "h" || char === "w") && !isAlphanumeric(raw[i - 1])) {
|
|
180
|
+
const url = /^(?:https?:\/\/|www\.)[^\s<>"`]*[^\s<>"`.,;:!?')\]]/.exec(raw.slice(i, to));
|
|
181
|
+
if (url) {
|
|
182
|
+
let text = url[0];
|
|
183
|
+
// A ")" after the URL is part of it only when the URL has a "(" without a partner.
|
|
184
|
+
const count = (pattern : RegExp) : number => (text.match(pattern) ?? []).length;
|
|
185
|
+
while (raw[i + text.length] === ")" && i + text.length < to && count(/\(/g) > count(/\)/g)) text += ")";
|
|
186
|
+
out.placeholder("url", text, i, i + text.length);
|
|
187
|
+
i += text.length;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (char === "*") {
|
|
193
|
+
const before = raw[i - 1];
|
|
194
|
+
const after = raw[i + 1];
|
|
195
|
+
const spaced = (before === undefined || /\s/.test(before)) && (after === undefined || /\s/.test(after));
|
|
196
|
+
if (spaced) out.char(i);
|
|
197
|
+
i++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (char === "_") {
|
|
202
|
+
if (isAlphanumeric(raw[i - 1]) && isAlphanumeric(raw[i + 1])) out.char(i);
|
|
203
|
+
i++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (char === "~" && next === "~") {
|
|
208
|
+
i += 2;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (char === "&") {
|
|
213
|
+
const entity = /^&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});/.exec(raw.slice(i, to));
|
|
214
|
+
const decoded = entity ? decodeEntity(entity[1]!) : null;
|
|
215
|
+
if (entity && decoded !== null) {
|
|
216
|
+
out.emit(decoded, i);
|
|
217
|
+
i += entity[0].length;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (char === "\"" || char === "“" || char === "'" || char === "‘") {
|
|
223
|
+
const end = scanQuote(raw, i, to, out);
|
|
224
|
+
if (end !== -1) {
|
|
225
|
+
i = end;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
out.char(i);
|
|
231
|
+
i++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function decodeEntity(name : string) : string | null {
|
|
236
|
+
if (name.startsWith("#x") || name.startsWith("#X")) return safeCodePoint(Number.parseInt(name.slice(2), 16));
|
|
237
|
+
if (name.startsWith("#")) return safeCodePoint(Number.parseInt(name.slice(1), 10));
|
|
238
|
+
return ENTITIES.get(name.toLowerCase()) ?? null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function safeCodePoint(value : number) : string | null {
|
|
242
|
+
return Number.isInteger(value) && value > 0 && value <= 0x10FFFF ? String.fromCodePoint(value) : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** This function handles `<...>`: autolinks, comments, HTML tags and JSX tags. The result is the index after it, or -1. */
|
|
246
|
+
function scanAngle(raw : string, i : number, to : number, out : Builder) : number {
|
|
247
|
+
const rest = raw.slice(i, to);
|
|
248
|
+
const autolink = /^<((?:https?|mailto|ftp):[^\s<>]*|[^\s<>@]+@[^\s<>]+\.[^\s<>]+)>/.exec(rest);
|
|
249
|
+
if (autolink) {
|
|
250
|
+
out.placeholder("url", autolink[1]!, i, i + autolink[0].length);
|
|
251
|
+
return i + autolink[0].length;
|
|
252
|
+
}
|
|
253
|
+
if (rest.startsWith("<!--")) {
|
|
254
|
+
const close = raw.indexOf("-->", i + 4);
|
|
255
|
+
return close === -1 || close + 3 > to ? to : close + 3;
|
|
256
|
+
}
|
|
257
|
+
const tag = /^<(\/?)([A-Za-z][A-Za-z0-9.:_-]*)?/.exec(rest);
|
|
258
|
+
if (!tag || (tag[2] === undefined && !/^<\/?>/.test(rest))) return -1;
|
|
259
|
+
const tagEnd = findAngleClose(raw, i + 1, to);
|
|
260
|
+
if (tagEnd === -1) return -1;
|
|
261
|
+
const name = (tag[2] ?? "").toLowerCase();
|
|
262
|
+
if (tag[1] === "" && CODE_ELEMENTS.has(name)) {
|
|
263
|
+
const closeTag = raw.toLowerCase().indexOf(`</${name}>`, tagEnd);
|
|
264
|
+
if (closeTag !== -1 && closeTag + name.length + 3 <= to) {
|
|
265
|
+
out.placeholder("code", raw.slice(tagEnd, closeTag).trim(), i, closeTag + name.length + 3);
|
|
266
|
+
return closeTag + name.length + 3;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (name === "br") out.emit(" ", i);
|
|
270
|
+
return tagEnd;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The index after the `>` that closes a tag. Quotes and `{...}` can contain `>`. */
|
|
274
|
+
function findAngleClose(raw : string, from : number, to : number) : number {
|
|
275
|
+
let quote : string | null = null;
|
|
276
|
+
let depth = 0;
|
|
277
|
+
for (let i = from; i < to; i++) {
|
|
278
|
+
const char = raw[i]!;
|
|
279
|
+
if (quote !== null) {
|
|
280
|
+
if (char === quote) quote = null;
|
|
281
|
+
} else if (char === "\"" || char === "'") {
|
|
282
|
+
quote = char;
|
|
283
|
+
} else if (char === "{") {
|
|
284
|
+
depth++;
|
|
285
|
+
} else if (char === "}") {
|
|
286
|
+
depth = Math.max(0, depth - 1);
|
|
287
|
+
} else if (char === "<" && depth === 0) {
|
|
288
|
+
return -1;
|
|
289
|
+
} else if (char === ">" && depth === 0) {
|
|
290
|
+
return i + 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return -1;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** This function handles `{@link ...}` in TSDoc and `{...}` in MDX. The result is the index after it, or -1. */
|
|
297
|
+
function scanBrace(raw : string, i : number, to : number, out : Builder, options : InlineOptions) : number {
|
|
298
|
+
const isTag = options.tsdoc && raw[i + 1] === "@";
|
|
299
|
+
if (!isTag && !options.mdx) return -1;
|
|
300
|
+
let depth = 0;
|
|
301
|
+
let quote : string | null = null;
|
|
302
|
+
for (let k = i; k < to; k++) {
|
|
303
|
+
const char = raw[k]!;
|
|
304
|
+
if (quote !== null) {
|
|
305
|
+
if (char === quote) quote = null;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (!isTag && (char === "\"" || char === "'" || char === "`")) {
|
|
309
|
+
quote = char;
|
|
310
|
+
} else if (char === "{") {
|
|
311
|
+
depth++;
|
|
312
|
+
} else if (char === "}") {
|
|
313
|
+
depth--;
|
|
314
|
+
if (depth === 0) {
|
|
315
|
+
const inner = raw.slice(i + 1, k).trim();
|
|
316
|
+
if (!isTag && inner.startsWith("/*") && inner.endsWith("*/")) return k + 1;
|
|
317
|
+
out.placeholder(isTag ? "link" : "expr", inner, i, k + 1);
|
|
318
|
+
return k + 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return -1;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
type LinkEnd = {
|
|
326
|
+
/** The index of the `]` after the link text. */
|
|
327
|
+
readonly textEnd : number;
|
|
328
|
+
/** The index after the link destination or reference. */
|
|
329
|
+
readonly end : number;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/** The end of an inline link `[text](url)` or a reference link `[text][ref]` that starts at `start`, or null. */
|
|
333
|
+
function linkEnd(raw : string, start : number, to : number) : LinkEnd | null {
|
|
334
|
+
let depth = 0;
|
|
335
|
+
let i = start;
|
|
336
|
+
let textEnd = -1;
|
|
337
|
+
while (i < to) {
|
|
338
|
+
const char = raw[i]!;
|
|
339
|
+
if (char === "\\") {
|
|
340
|
+
i += 2;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (char === "`") {
|
|
344
|
+
let run = 1;
|
|
345
|
+
while (raw[i + run] === "`") run++;
|
|
346
|
+
const close = findBacktickRun(raw, i + run, run);
|
|
347
|
+
i = close === -1 ? i + run : close + run;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (char === "[") depth++;
|
|
351
|
+
if (char === "]") {
|
|
352
|
+
depth--;
|
|
353
|
+
if (depth === 0) {
|
|
354
|
+
textEnd = i;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
i++;
|
|
359
|
+
}
|
|
360
|
+
if (textEnd === -1) return null;
|
|
361
|
+
const after = raw[textEnd + 1];
|
|
362
|
+
if (after === "(") {
|
|
363
|
+
let parens = 0;
|
|
364
|
+
let quote : string | null = null;
|
|
365
|
+
for (let k = textEnd + 1; k < to; k++) {
|
|
366
|
+
const char = raw[k]!;
|
|
367
|
+
if (quote !== null) {
|
|
368
|
+
if (char === quote) quote = null;
|
|
369
|
+
} else if (char === "\"" && parens > 0) {
|
|
370
|
+
quote = char;
|
|
371
|
+
} else if (char === "(") {
|
|
372
|
+
parens++;
|
|
373
|
+
} else if (char === ")") {
|
|
374
|
+
parens--;
|
|
375
|
+
if (parens === 0) return { textEnd, end : k + 1 };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
if (after === "[") {
|
|
381
|
+
const close = raw.indexOf("]", textEnd + 2);
|
|
382
|
+
if (close !== -1 && close < to) return { textEnd, end : close + 1 };
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* This function handles quoted text. Double quotes always pair. A single
|
|
389
|
+
* quote pairs only when it cannot be an apostrophe. Then white space or an
|
|
390
|
+
* opening bracket comes before it, and white space or punctuation comes
|
|
391
|
+
* after its partner. The result is the index after the quote, or -1.
|
|
392
|
+
*/
|
|
393
|
+
function scanQuote(raw : string, i : number, to : number, out : Builder) : number {
|
|
394
|
+
const open = raw[i]!;
|
|
395
|
+
const close = open === "“" ? "”" : open === "‘" ? "’" : open;
|
|
396
|
+
const before = raw[i - 1];
|
|
397
|
+
if (isAlphanumeric(before)) return -1;
|
|
398
|
+
const single = open === "'" || open === "‘";
|
|
399
|
+
if (single && before !== undefined && !/[\s([{]/.test(before)) return -1;
|
|
400
|
+
if (raw[i + 1] === undefined || /\s/.test(raw[i + 1]!)) return -1;
|
|
401
|
+
const limit = Math.min(to, i + (single ? 60 : 300));
|
|
402
|
+
for (let k = i + 1; k < limit; k++) {
|
|
403
|
+
if (raw[k] !== close) continue;
|
|
404
|
+
const after = raw[k + 1];
|
|
405
|
+
if (single && after !== undefined && !/[\s.,;:!?)\]}]/.test(after)) continue;
|
|
406
|
+
if (!single && isAlphanumeric(after)) continue;
|
|
407
|
+
if (/\s/.test(raw[k - 1]!)) continue;
|
|
408
|
+
const text = raw.slice(i + 1, k);
|
|
409
|
+
out.placeholder("quote", text, i, k + 1);
|
|
410
|
+
const final = text[text.length - 1];
|
|
411
|
+
// A quoted sentence can end the sentence around it.
|
|
412
|
+
if (final === "." || final === "!" || final === "?") out.emit(final, k - 1);
|
|
413
|
+
return k + 1;
|
|
414
|
+
}
|
|
415
|
+
return -1;
|
|
416
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractJsxText } from "./jsx.js";
|
|
3
|
+
|
|
4
|
+
function blocksOf(source : string) {
|
|
5
|
+
return extractJsxText(source, "page.tsx").blocks.map(({ block }) => ({
|
|
6
|
+
kind : block.kind,
|
|
7
|
+
text : block.lines.map(line => line.text).join("|"),
|
|
8
|
+
offset : block.lines[0]!.offset,
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("extractJsxText", () => {
|
|
13
|
+
it("gives the text of an element as one block, with inline elements and expressions in it", () => {
|
|
14
|
+
const source = "const a = <p>There are no results. Use <code>pnpm results</code> for {count} runs.</p>;";
|
|
15
|
+
expect(blocksOf(source)).toEqual([{
|
|
16
|
+
kind : "paragraph",
|
|
17
|
+
text : "There are no results. Use |`x`| for |{x}| runs.",
|
|
18
|
+
offset : source.indexOf("There"),
|
|
19
|
+
}]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("gives headings and labels their own kinds", () => {
|
|
23
|
+
const source = "const a = <div><h2>The results</h2><button>Start the run</button></div>;";
|
|
24
|
+
expect(blocksOf(source).map(b => [b.kind, b.text])).toEqual([["heading", "The results"], ["table-cell", "Start the run"]]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("divides the text at block elements, and finds the JSX inside expressions", () => {
|
|
28
|
+
const source = "const a = <section>First text.<ul>{items.map(i => <li key={i}>Item text.</li>)}</ul>Last text.</section>;";
|
|
29
|
+
expect(blocksOf(source).map(b => b.text)).toEqual(["First text.", "Item text.", "Last text."]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("gives the text attributes, and ignores the other attributes", () => {
|
|
33
|
+
const source = 'const a = <PlotFigure title="The drift chart" className="wide" description={"x"} aria-label="Drift" />;';
|
|
34
|
+
expect(blocksOf(source).map(b => b.text)).toEqual(["The drift chart", "Drift"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("ignores elements without words", () => {
|
|
38
|
+
expect(blocksOf("const a = <p>{value} {unit}</p>;")).toEqual([]);
|
|
39
|
+
expect(blocksOf("const a = <p> </p>;")).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps the text of inline elements in the paragraph", () => {
|
|
43
|
+
const source = "const a = <p>Read the <Link to=\"/docs\">documentation</Link> first.</p>;";
|
|
44
|
+
expect(blocksOf(source).map(b => b.text)).toEqual(["Read the |documentation| first."]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prose extraction from the JSX of TSX and JSX files: the text that a user
|
|
3
|
+
* interface shows.
|
|
4
|
+
*
|
|
5
|
+
* An element with text children is one block. Inline elements (`strong`,
|
|
6
|
+
* `a`, `code` and the other elements in `INLINE_ELEMENTS`) and `{...}`
|
|
7
|
+
* expressions stay in the text of their parent. A code element and an
|
|
8
|
+
* expression become one placeholder, which counts as one word. An element
|
|
9
|
+
* with other children gives one block for each run of its text between
|
|
10
|
+
* those children. The JSX inside an expression gives its own blocks.
|
|
11
|
+
*
|
|
12
|
+
* The values of the attributes in `TEXT_ATTRIBUTES` (for example `title`
|
|
13
|
+
* and `aria-label`) are blocks too, when they are string literals.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import ts from "typescript";
|
|
17
|
+
import type { ExtractedBlock, BlockOrigin } from "../units.js";
|
|
18
|
+
import type { ProseBlock } from "./blocks.js";
|
|
19
|
+
import { splitLines, type Line } from "./lines.js";
|
|
20
|
+
import type { Extraction } from "./markdown.js";
|
|
21
|
+
|
|
22
|
+
/** Elements whose text stays in the text of the parent element. */
|
|
23
|
+
const INLINE_ELEMENTS = new Set([
|
|
24
|
+
"a", "abbr", "b", "bdi", "bdo", "br", "cite", "code", "data", "dfn", "em", "i", "kbd", "mark", "q", "s",
|
|
25
|
+
"samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "Link", "NavLink",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Inline elements whose content is code: each one is one placeholder. */
|
|
29
|
+
const CODE_ELEMENTS = new Set(["code", "kbd", "samp", "var"]);
|
|
30
|
+
|
|
31
|
+
/** Elements whose text is a heading. */
|
|
32
|
+
const HEADING_ELEMENTS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
33
|
+
|
|
34
|
+
/** Elements whose text is a label or a short fragment, not a sentence. The word rules apply, the sentence rules do not. */
|
|
35
|
+
const FRAGMENT_ELEMENTS = new Set(["button", "label", "legend", "option", "th", "td", "dt", "summary", "caption", "title"]);
|
|
36
|
+
|
|
37
|
+
/** Attributes whose string value is text that a reader sees or hears. */
|
|
38
|
+
const TEXT_ATTRIBUTES = new Set(["title", "aria-label", "aria-description", "alt", "placeholder", "label", "caption", "description", "summary"]);
|
|
39
|
+
|
|
40
|
+
const ORIGIN : BlockOrigin = { source : "jsx", tsdocSection : null, tsdocTag : null };
|
|
41
|
+
|
|
42
|
+
function tagName(node : ts.JsxElement | ts.JsxSelfClosingElement) : string {
|
|
43
|
+
const name = ts.isJsxElement(node) ? node.openingElement.tagName : node.tagName;
|
|
44
|
+
return name.getText();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isInline(child : ts.JsxChild) : boolean {
|
|
48
|
+
if (ts.isJsxText(child) || ts.isJsxExpression(child)) return true;
|
|
49
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) return INLINE_ELEMENTS.has(tagName(child));
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function kindOf(name : string) : { kind : ProseBlock["kind"]; depth : number } {
|
|
54
|
+
if (HEADING_ELEMENTS.has(name)) return { kind : "heading", depth : Number(name.slice(1)) };
|
|
55
|
+
if (FRAGMENT_ELEMENTS.has(name)) return { kind : "table-cell", depth : 0 };
|
|
56
|
+
return { kind : "paragraph", depth : 0 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** This function extracts the text blocks of the JSX of a file. */
|
|
60
|
+
export function extractJsxText(text : string, fileName : string) : Extraction {
|
|
61
|
+
const sourceFile = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
62
|
+
const blocks : ExtractedBlock[] = [];
|
|
63
|
+
|
|
64
|
+
const push = (kind : ProseBlock["kind"], lines : readonly Line[], depth : number) : void => {
|
|
65
|
+
blocks.push({ block : { kind, lines, list : null, quote : false, headingDepth : depth, tableHeader : false }, origin : ORIGIN });
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The lines of one inline child. The JSX inside an expression goes to `visit`. */
|
|
69
|
+
const linesOf = (child : ts.JsxChild) : { lines : Line[]; words : boolean } => {
|
|
70
|
+
if (ts.isJsxText(child)) {
|
|
71
|
+
const start = child.getFullStart();
|
|
72
|
+
const raw = text.slice(start, child.getEnd());
|
|
73
|
+
return { lines : splitLines(raw, start), words : /\p{L}/u.test(raw) };
|
|
74
|
+
}
|
|
75
|
+
if (ts.isJsxExpression(child)) {
|
|
76
|
+
if (!child.expression) return { lines : [], words : false };
|
|
77
|
+
ts.forEachChild(child.expression, visit);
|
|
78
|
+
return { lines : [{ text : "{x}", offset : child.getStart() }], words : false };
|
|
79
|
+
}
|
|
80
|
+
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
|
81
|
+
const name = tagName(child);
|
|
82
|
+
if (ts.isJsxSelfClosingElement(child) || CODE_ELEMENTS.has(name)) {
|
|
83
|
+
if (ts.isJsxSelfClosingElement(child) || name === "br") return { lines : [], words : false };
|
|
84
|
+
return { lines : [{ text : "`x`", offset : child.getStart() }], words : false };
|
|
85
|
+
}
|
|
86
|
+
visitAttributes(child.openingElement.attributes);
|
|
87
|
+
const parts = child.children.map(linesOf);
|
|
88
|
+
return { lines : parts.flatMap(part => part.lines), words : parts.some(part => part.words) };
|
|
89
|
+
}
|
|
90
|
+
return { lines : [], words : false };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const addRun = (children : readonly ts.JsxChild[], kind : ProseBlock["kind"], depth : number) : void => {
|
|
94
|
+
const parts = children.map(linesOf);
|
|
95
|
+
if (parts.some(part => part.words)) push(kind, parts.flatMap(part => part.lines), depth);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const visitChildren = (children : ts.NodeArray<ts.JsxChild>, name : string) : void => {
|
|
99
|
+
const { kind, depth } = kindOf(name);
|
|
100
|
+
let run : ts.JsxChild[] = [];
|
|
101
|
+
for (const child of children) {
|
|
102
|
+
if (isInline(child)) {
|
|
103
|
+
run.push(child);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
addRun(run, kind, depth);
|
|
107
|
+
run = [];
|
|
108
|
+
visit(child);
|
|
109
|
+
}
|
|
110
|
+
addRun(run, kind, depth);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function visitAttributes(attributes : ts.JsxAttributes) : void {
|
|
114
|
+
for (const property of attributes.properties) {
|
|
115
|
+
if (!ts.isJsxAttribute(property) || !property.initializer) continue;
|
|
116
|
+
const value = property.initializer;
|
|
117
|
+
if (ts.isJsxExpression(value)) {
|
|
118
|
+
if (value.expression) ts.forEachChild(value.expression, visit);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!TEXT_ATTRIBUTES.has(property.name.getText())) continue;
|
|
122
|
+
if (ts.isStringLiteral(value) && /\p{L}/u.test(value.text)) {
|
|
123
|
+
push("table-cell", [{ text : value.text, offset : value.getStart() + 1 }], 0);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function visit(node : ts.Node) : void {
|
|
129
|
+
if (ts.isJsxElement(node)) {
|
|
130
|
+
visitAttributes(node.openingElement.attributes);
|
|
131
|
+
visitChildren(node.children, tagName(node));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (ts.isJsxSelfClosingElement(node)) {
|
|
135
|
+
visitAttributes(node.attributes);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (ts.isJsxFragment(node)) {
|
|
139
|
+
visitChildren(node.children, "fragment");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
ts.forEachChild(node, visit);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
visit(sourceFile);
|
|
146
|
+
return { blocks, suppressions : [] };
|
|
147
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { indentWidth, isBlank, LineIndex, sliceLine, splitLines, stripIndent } from "./lines.js";
|
|
3
|
+
|
|
4
|
+
describe("splitLines", () => {
|
|
5
|
+
it("gives each line with the offset of its first character", () => {
|
|
6
|
+
expect(splitLines("ab\ncd\n")).toEqual([
|
|
7
|
+
{ text : "ab", offset : 0 },
|
|
8
|
+
{ text : "cd", offset : 3 },
|
|
9
|
+
{ text : "", offset : 6 },
|
|
10
|
+
]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("removes the \\r of a CRLF line end and adds the base offset", () => {
|
|
14
|
+
expect(splitLines("a\r\nb", 10)).toEqual([
|
|
15
|
+
{ text : "a", offset : 10 },
|
|
16
|
+
{ text : "b", offset : 13 },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("LineIndex", () => {
|
|
22
|
+
it("changes offsets into 1-based lines and columns", () => {
|
|
23
|
+
const index = new LineIndex("ab\ncd\n\nx");
|
|
24
|
+
expect(index.position(0)).toEqual({ line : 1, column : 1 });
|
|
25
|
+
expect(index.position(1)).toEqual({ line : 1, column : 2 });
|
|
26
|
+
expect(index.position(3)).toEqual({ line : 2, column : 1 });
|
|
27
|
+
expect(index.position(6)).toEqual({ line : 3, column : 1 });
|
|
28
|
+
expect(index.position(7)).toEqual({ line : 4, column : 1 });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("indentation helpers", () => {
|
|
33
|
+
it("counts spaces and tabs as columns", () => {
|
|
34
|
+
expect(indentWidth(" x")).toBe(3);
|
|
35
|
+
expect(indentWidth("\tx")).toBe(4);
|
|
36
|
+
expect(indentWidth(" \tx")).toBe(4);
|
|
37
|
+
expect(indentWidth("x")).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("removes indentation and moves the offset", () => {
|
|
41
|
+
expect(stripIndent({ text : " code", offset : 5 }, 2)).toEqual({ text : " code", offset : 7 });
|
|
42
|
+
expect(stripIndent({ text : " x", offset : 0 }, 4)).toEqual({ text : "x", offset : 1 });
|
|
43
|
+
expect(stripIndent({ text : "\tx", offset : 0 }, 4)).toEqual({ text : "x", offset : 1 });
|
|
44
|
+
expect(sliceLine({ text : "abc", offset : 1 }, 10)).toEqual({ text : "", offset : 4 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("finds blank lines", () => {
|
|
48
|
+
expect(isBlank(" \t ")).toBe(true);
|
|
49
|
+
expect(isBlank(" a ")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|