@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.
Files changed (227) hide show
  1. package/README.md +224 -1
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +9 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/config.d.ts +103 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +251 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/dictionary.d.ts +74 -0
  12. package/dist/dictionary.d.ts.map +1 -0
  13. package/dist/dictionary.js +169 -0
  14. package/dist/dictionary.js.map +1 -0
  15. package/dist/files.d.ts +35 -0
  16. package/dist/files.d.ts.map +1 -0
  17. package/dist/files.js +181 -0
  18. package/dist/files.js.map +1 -0
  19. package/dist/format.d.ts +21 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +61 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +17 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/lexicon.d.ts +33 -0
  28. package/dist/lexicon.d.ts.map +1 -0
  29. package/dist/lexicon.js +71 -0
  30. package/dist/lexicon.js.map +1 -0
  31. package/dist/lint.d.ts +28 -0
  32. package/dist/lint.d.ts.map +1 -0
  33. package/dist/lint.js +132 -0
  34. package/dist/lint.js.map +1 -0
  35. package/dist/rules/about-quantity.d.ts +3 -0
  36. package/dist/rules/about-quantity.d.ts.map +1 -0
  37. package/dist/rules/about-quantity.js +34 -0
  38. package/dist/rules/about-quantity.js.map +1 -0
  39. package/dist/rules/index.d.ts +8 -0
  40. package/dist/rules/index.d.ts.map +1 -0
  41. package/dist/rules/index.js +35 -0
  42. package/dist/rules/index.js.map +1 -0
  43. package/dist/rules/ing-verb.d.ts +9 -0
  44. package/dist/rules/ing-verb.d.ts.map +1 -0
  45. package/dist/rules/ing-verb.js +60 -0
  46. package/dist/rules/ing-verb.js.map +1 -0
  47. package/dist/rules/latin-abbreviation.d.ts +3 -0
  48. package/dist/rules/latin-abbreviation.d.ts.map +1 -0
  49. package/dist/rules/latin-abbreviation.js +48 -0
  50. package/dist/rules/latin-abbreviation.js.map +1 -0
  51. package/dist/rules/missing-subject.d.ts +16 -0
  52. package/dist/rules/missing-subject.d.ts.map +1 -0
  53. package/dist/rules/missing-subject.js +138 -0
  54. package/dist/rules/missing-subject.js.map +1 -0
  55. package/dist/rules/modal-verb.d.ts +3 -0
  56. package/dist/rules/modal-verb.d.ts.map +1 -0
  57. package/dist/rules/modal-verb.js +72 -0
  58. package/dist/rules/modal-verb.js.map +1 -0
  59. package/dist/rules/noun-cluster.d.ts +17 -0
  60. package/dist/rules/noun-cluster.d.ts.map +1 -0
  61. package/dist/rules/noun-cluster.js +83 -0
  62. package/dist/rules/noun-cluster.js.map +1 -0
  63. package/dist/rules/paragraph-length.d.ts +3 -0
  64. package/dist/rules/paragraph-length.d.ts.map +1 -0
  65. package/dist/rules/paragraph-length.js +19 -0
  66. package/dist/rules/paragraph-length.js.map +1 -0
  67. package/dist/rules/passive-instruction.d.ts +15 -0
  68. package/dist/rules/passive-instruction.d.ts.map +1 -0
  69. package/dist/rules/passive-instruction.js +38 -0
  70. package/dist/rules/passive-instruction.js.map +1 -0
  71. package/dist/rules/pronouns.d.ts +4 -0
  72. package/dist/rules/pronouns.d.ts.map +1 -0
  73. package/dist/rules/pronouns.js +57 -0
  74. package/dist/rules/pronouns.js.map +1 -0
  75. package/dist/rules/rule.d.ts +46 -0
  76. package/dist/rules/rule.d.ts.map +1 -0
  77. package/dist/rules/rule.js +31 -0
  78. package/dist/rules/rule.js.map +1 -0
  79. package/dist/rules/semicolon.d.ts +3 -0
  80. package/dist/rules/semicolon.d.ts.map +1 -0
  81. package/dist/rules/semicolon.js +20 -0
  82. package/dist/rules/semicolon.js.map +1 -0
  83. package/dist/rules/sentence-length.d.ts +3 -0
  84. package/dist/rules/sentence-length.d.ts.map +1 -0
  85. package/dist/rules/sentence-length.js +31 -0
  86. package/dist/rules/sentence-length.js.map +1 -0
  87. package/dist/rules/unknown-word.d.ts +9 -0
  88. package/dist/rules/unknown-word.d.ts.map +1 -0
  89. package/dist/rules/unknown-word.js +49 -0
  90. package/dist/rules/unknown-word.js.map +1 -0
  91. package/dist/rules/word-list-matcher.d.ts +25 -0
  92. package/dist/rules/word-list-matcher.d.ts.map +1 -0
  93. package/dist/rules/word-list-matcher.js +60 -0
  94. package/dist/rules/word-list-matcher.js.map +1 -0
  95. package/dist/rules/word-list.d.ts +3 -0
  96. package/dist/rules/word-list.d.ts.map +1 -0
  97. package/dist/rules/word-list.js +26 -0
  98. package/dist/rules/word-list.js.map +1 -0
  99. package/dist/run-cli.d.ts +27 -0
  100. package/dist/run-cli.d.ts.map +1 -0
  101. package/dist/run-cli.js +176 -0
  102. package/dist/run-cli.js.map +1 -0
  103. package/dist/source/blocks.d.ts +55 -0
  104. package/dist/source/blocks.d.ts.map +1 -0
  105. package/dist/source/blocks.js +464 -0
  106. package/dist/source/blocks.js.map +1 -0
  107. package/dist/source/inline.d.ts +42 -0
  108. package/dist/source/inline.d.ts.map +1 -0
  109. package/dist/source/inline.js +396 -0
  110. package/dist/source/inline.js.map +1 -0
  111. package/dist/source/jsx.d.ts +18 -0
  112. package/dist/source/jsx.d.ts.map +1 -0
  113. package/dist/source/jsx.js +137 -0
  114. package/dist/source/jsx.js.map +1 -0
  115. package/dist/source/lines.d.ts +34 -0
  116. package/dist/source/lines.d.ts.map +1 -0
  117. package/dist/source/lines.js +83 -0
  118. package/dist/source/lines.js.map +1 -0
  119. package/dist/source/markdown.d.ts +23 -0
  120. package/dist/source/markdown.d.ts.map +1 -0
  121. package/dist/source/markdown.js +42 -0
  122. package/dist/source/markdown.js.map +1 -0
  123. package/dist/source/suppressions.d.ts +38 -0
  124. package/dist/source/suppressions.d.ts.map +1 -0
  125. package/dist/source/suppressions.js +69 -0
  126. package/dist/source/suppressions.js.map +1 -0
  127. package/dist/source/tsdoc.d.ts +40 -0
  128. package/dist/source/tsdoc.d.ts.map +1 -0
  129. package/dist/source/tsdoc.js +176 -0
  130. package/dist/source/tsdoc.js.map +1 -0
  131. package/dist/text/grammar.d.ts +91 -0
  132. package/dist/text/grammar.d.ts.map +1 -0
  133. package/dist/text/grammar.js +289 -0
  134. package/dist/text/grammar.js.map +1 -0
  135. package/dist/text/phrases.d.ts +30 -0
  136. package/dist/text/phrases.d.ts.map +1 -0
  137. package/dist/text/phrases.js +65 -0
  138. package/dist/text/phrases.js.map +1 -0
  139. package/dist/text/sentences.d.ts +45 -0
  140. package/dist/text/sentences.d.ts.map +1 -0
  141. package/dist/text/sentences.js +141 -0
  142. package/dist/text/sentences.js.map +1 -0
  143. package/dist/text/tokens.d.ts +35 -0
  144. package/dist/text/tokens.d.ts.map +1 -0
  145. package/dist/text/tokens.js +113 -0
  146. package/dist/text/tokens.js.map +1 -0
  147. package/dist/text/words.d.ts +58 -0
  148. package/dist/text/words.d.ts.map +1 -0
  149. package/dist/text/words.js +159 -0
  150. package/dist/text/words.js.map +1 -0
  151. package/dist/types.d.ts +18 -0
  152. package/dist/types.d.ts.map +1 -0
  153. package/dist/types.js +20 -0
  154. package/dist/types.js.map +1 -0
  155. package/dist/units.d.ts +47 -0
  156. package/dist/units.d.ts.map +1 -0
  157. package/dist/units.js +42 -0
  158. package/dist/units.js.map +1 -0
  159. package/dist/word-list.d.ts +34 -0
  160. package/dist/word-list.d.ts.map +1 -0
  161. package/dist/word-list.js +90 -0
  162. package/dist/word-list.js.map +1 -0
  163. package/package.json +41 -4
  164. package/src/cli.ts +9 -0
  165. package/src/config.test.ts +188 -0
  166. package/src/config.ts +318 -0
  167. package/src/dictionary.test.ts +82 -0
  168. package/src/dictionary.ts +177 -0
  169. package/src/files.test.ts +107 -0
  170. package/src/files.ts +191 -0
  171. package/src/format.ts +78 -0
  172. package/src/index.test.ts +28 -0
  173. package/src/index.ts +34 -0
  174. package/src/lexicon.ts +92 -0
  175. package/src/lint.test.ts +182 -0
  176. package/src/lint.ts +135 -0
  177. package/src/rules/about-quantity.ts +33 -0
  178. package/src/rules/index.ts +40 -0
  179. package/src/rules/ing-verb.test.ts +48 -0
  180. package/src/rules/ing-verb.ts +54 -0
  181. package/src/rules/latin-abbreviation.ts +46 -0
  182. package/src/rules/missing-subject.test.ts +53 -0
  183. package/src/rules/missing-subject.ts +129 -0
  184. package/src/rules/modal-verb.test.ts +45 -0
  185. package/src/rules/modal-verb.ts +72 -0
  186. package/src/rules/noun-cluster.test.ts +49 -0
  187. package/src/rules/noun-cluster.ts +82 -0
  188. package/src/rules/paragraph-length.test.ts +30 -0
  189. package/src/rules/paragraph-length.ts +19 -0
  190. package/src/rules/passive-instruction.test.ts +38 -0
  191. package/src/rules/passive-instruction.ts +37 -0
  192. package/src/rules/pronouns.test.ts +38 -0
  193. package/src/rules/pronouns.ts +54 -0
  194. package/src/rules/punctuation.test.ts +59 -0
  195. package/src/rules/rule.ts +73 -0
  196. package/src/rules/semicolon.ts +19 -0
  197. package/src/rules/sentence-length.test.ts +52 -0
  198. package/src/rules/sentence-length.ts +32 -0
  199. package/src/rules/unknown-word.test.ts +81 -0
  200. package/src/rules/unknown-word.ts +43 -0
  201. package/src/rules/word-list-matcher.ts +68 -0
  202. package/src/rules/word-list.test.ts +110 -0
  203. package/src/rules/word-list.ts +25 -0
  204. package/src/run-cli.test.ts +189 -0
  205. package/src/run-cli.ts +193 -0
  206. package/src/source/blocks.ts +534 -0
  207. package/src/source/inline.test.ts +92 -0
  208. package/src/source/inline.ts +416 -0
  209. package/src/source/jsx.test.ts +46 -0
  210. package/src/source/jsx.ts +147 -0
  211. package/src/source/lines.test.ts +51 -0
  212. package/src/source/lines.ts +91 -0
  213. package/src/source/markdown.test.ts +210 -0
  214. package/src/source/markdown.ts +49 -0
  215. package/src/source/suppressions.ts +79 -0
  216. package/src/source/tsdoc.test.ts +137 -0
  217. package/src/source/tsdoc.ts +189 -0
  218. package/src/text/grammar.ts +304 -0
  219. package/src/text/phrases.ts +72 -0
  220. package/src/text/sentences.ts +148 -0
  221. package/src/text/text.test.ts +262 -0
  222. package/src/text/tokens.ts +135 -0
  223. package/src/text/words.ts +183 -0
  224. package/src/types.ts +39 -0
  225. package/src/units.ts +86 -0
  226. package/src/word-list.ts +128 -0
  227. package/ste.config.schema.json +87 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Prose extraction from the doc comments (`/** ... *\/`) of TypeScript and
3
+ * JavaScript files.
4
+ *
5
+ * The TypeScript compiler parses the file. The extractor then examines only
6
+ * the trivia between tokens. Thus, text in strings, template literals,
7
+ * regular expressions and JSX does not become a comment.
8
+ */
9
+
10
+ import ts from "typescript";
11
+ import type { BlockOrigin, ExtractedBlock, TsdocSection } from "../units.js";
12
+ import { parseBlocks, parseDirective } from "./blocks.js";
13
+ import { sliceLine, splitLines, type Line } from "./lines.js";
14
+ import type { Extraction } from "./markdown.js";
15
+ import { SuppressionCollector } from "./suppressions.js";
16
+
17
+ export type CommentKind = "line" | "block" | "doc";
18
+
19
+ export type CommentRange = {
20
+ readonly kind : CommentKind;
21
+ /** The file offset of the first `/`. */
22
+ readonly pos : number;
23
+ /** The file offset after the comment. */
24
+ readonly end : number;
25
+ };
26
+
27
+ /** Tags whose content is not prose: code, values, names and other data. */
28
+ const SKIPPED_TAGS = new Set([
29
+ "example", "default", "defaultvalue", "since", "version", "author", "license", "module", "category",
30
+ "group", "file", "fileoverview", "overview", "typedef", "type", "callback", "enum", "class", "constructor",
31
+ "extends", "augments", "implements", "satisfies", "overload", "import", "link", "linkcode", "linkplain",
32
+ "jsx", "jsxfrag", "jsximportsource", "jsxruntime", "ts-check", "ts-nocheck", "ts-ignore", "ts-expect-error",
33
+ "vitest-environment", "jest-environment", "eslint-disable", "copyright", "summary-only",
34
+ ]);
35
+
36
+ /** Tags whose content is the same type of text as the summary. */
37
+ const REMARKS_TAGS = new Set(["remarks", "privateremarks", "description", "summary", "classdesc"]);
38
+
39
+ /** Tags that have a name before their text: `@param name - text`. */
40
+ const NAMED_TAGS = new Set(["param", "typeparam", "template", "property", "prop", "arg", "argument"]);
41
+
42
+ /** Tags that can have a type before their text: `@throws {Error} text`. */
43
+ const TYPED_TAGS = new Set(["returns", "return", "throws", "exception", "yields", "yield"]);
44
+
45
+ const TAG_LINE = /^[ \t]*@([A-Za-z][\w-]*)(?=[\s{]|$)/;
46
+
47
+ function scriptKind(fileName : string) : ts.ScriptKind {
48
+ const lower = fileName.toLowerCase();
49
+ if (lower.endsWith(".tsx")) return ts.ScriptKind.TSX;
50
+ if (lower.endsWith(".jsx")) return ts.ScriptKind.JSX;
51
+ if (/\.[mc]?js$/.test(lower)) return ts.ScriptKind.JS;
52
+ return ts.ScriptKind.TS;
53
+ }
54
+
55
+ /** This function finds all comments in a file. The comments come in file order. */
56
+ export function collectComments(text : string, fileName : string) : CommentRange[] {
57
+ const sourceFile = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, scriptKind(fileName));
58
+ const comments : CommentRange[] = [];
59
+ const scanTrivia = (from : number, to : number) : void => {
60
+ let i = from;
61
+ if (i === 0 && text.startsWith("#!")) {
62
+ const lineEnd = text.indexOf("\n");
63
+ i = lineEnd === -1 ? to : lineEnd;
64
+ }
65
+ while (i < to) {
66
+ if (text[i] === "/" && text[i + 1] === "/") {
67
+ const lineEnd = text.indexOf("\n", i);
68
+ const end = lineEnd === -1 || lineEnd > to ? to : lineEnd;
69
+ comments.push({ kind : "line", pos : i, end });
70
+ i = end;
71
+ } else if (text[i] === "/" && text[i + 1] === "*") {
72
+ const close = text.indexOf("*/", i + 2);
73
+ const end = close === -1 ? to : Math.min(close + 2, to);
74
+ const isDoc = text.startsWith("/**", i) && !text.startsWith("/**/", i);
75
+ comments.push({ kind : isDoc ? "doc" : "block", pos : i, end });
76
+ i = end;
77
+ } else {
78
+ i++;
79
+ }
80
+ }
81
+ };
82
+ const isJsDoc = (node : ts.Node) : boolean => node.kind >= ts.SyntaxKind.FirstJSDocNode && node.kind <= ts.SyntaxKind.LastJSDocNode;
83
+ const visit = (node : ts.Node) : void => {
84
+ // A token can have a JSDoc child, for example the end-of-file token after a final doc comment. It is still a token.
85
+ const children = node.getChildren(sourceFile).filter((child) => !isJsDoc(child));
86
+ if (children.length === 0) {
87
+ scanTrivia(node.getFullStart(), node.getStart(sourceFile));
88
+ return;
89
+ }
90
+ for (const child of children) visit(child);
91
+ };
92
+ visit(sourceFile);
93
+ return comments;
94
+ }
95
+
96
+ /**
97
+ * This function removes the comment syntax from the lines of a doc comment:
98
+ *
99
+ * - `/**` on the first line
100
+ * - `*\/` on the last line
101
+ * - The `*` and one space at the start of the other lines.
102
+ */
103
+ export function docCommentLines(text : string, comment : CommentRange) : Line[] {
104
+ const inner = text.slice(comment.pos + 3, Math.max(comment.pos + 3, comment.end - 2));
105
+ return splitLines(inner, comment.pos + 3).map((line, index) => {
106
+ if (index === 0) return line;
107
+ const prefix = /^[ \t]*\*(?!\/)[ \t]?/.exec(line.text);
108
+ if (prefix) return sliceLine(line, prefix[0].length);
109
+ return sliceLine(line, line.text.length - line.text.trimStart().length);
110
+ });
111
+ }
112
+
113
+ type Section = {
114
+ readonly section : TsdocSection;
115
+ readonly tag : string | null;
116
+ readonly lines : Line[];
117
+ };
118
+
119
+ /** This function removes the tag, and the type and the name after the tag, from the first line of a block tag. */
120
+ function tagContent(line : Line, tag : string, tagEnd : number) : Line {
121
+ const rest = sliceLine(line, tagEnd);
122
+ const lower = tag.toLowerCase();
123
+ let prefix : RegExp;
124
+ if (NAMED_TAGS.has(lower)) prefix = /^\s*(?:\{[^}]*\}\s*)?(?:\[[^\]]*\]|[\w$.]+)?\s*(?:-\s+|-$)?/;
125
+ else if (TYPED_TAGS.has(lower)) prefix = /^\s*(?:\{[^}]*\}\s*)?(?:-\s+)?/;
126
+ else prefix = /^\s*(?:-\s+)?/;
127
+ return sliceLine(rest, prefix.exec(rest.text)![0].length);
128
+ }
129
+
130
+ /** This function divides the lines of a doc comment into the summary and the block tag sections. */
131
+ export function splitSections(lines : readonly Line[]) : Section[] {
132
+ const sections : Section[] = [];
133
+ let current : Section | null = { section : "summary", tag : null, lines : [] };
134
+ let fence : string | null = null;
135
+ for (const line of lines) {
136
+ const fenceMatch = /^[ \t]*(`{3,}|~{3,})/.exec(line.text);
137
+ if (fence !== null) {
138
+ if (fenceMatch && fenceMatch[1]![0] === fence[0] && fenceMatch[1]!.length >= fence.length) fence = null;
139
+ current?.lines.push(line);
140
+ continue;
141
+ }
142
+ if (fenceMatch) fence = fenceMatch[1]!;
143
+ const tag = fence === null ? TAG_LINE.exec(line.text) : null;
144
+ if (tag === null) {
145
+ current?.lines.push(line);
146
+ continue;
147
+ }
148
+ if (current !== null) sections.push(current);
149
+ const name = tag[1]!;
150
+ const lower = name.toLowerCase();
151
+ if (SKIPPED_TAGS.has(lower)) {
152
+ current = null;
153
+ continue;
154
+ }
155
+ current = {
156
+ section : REMARKS_TAGS.has(lower) ? "remarks" : "tag",
157
+ tag : name,
158
+ lines : [tagContent(line, name, tag[0].length)],
159
+ };
160
+ }
161
+ if (current !== null) sections.push(current);
162
+ return sections.filter((section) => section.lines.length > 0);
163
+ }
164
+
165
+ /** This function extracts the prose blocks of the doc comments of a file, and the suppressions. */
166
+ export function extractTsdoc(text : string, fileName : string) : Extraction {
167
+ const collector = new SuppressionCollector();
168
+ const blocks : ExtractedBlock[] = [];
169
+ for (const comment of collectComments(text, fileName)) {
170
+ if (comment.kind !== "doc") {
171
+ const body = comment.kind === "line" ? text.slice(comment.pos + 2, comment.end) : text.slice(comment.pos + 2, comment.end - 2);
172
+ const directive = parseDirective(body, comment.pos);
173
+ if (directive !== null) collector.directive(directive);
174
+ continue;
175
+ }
176
+ collector.block(comment.pos, comment.end);
177
+ for (const section of splitSections(docCommentLines(text, comment))) {
178
+ const origin : BlockOrigin = { source : "tsdoc", tsdocSection : section.section, tsdocTag : section.tag };
179
+ for (const block of parseBlocks(section.lines, { mdx : false })) {
180
+ if (block.kind === "directive") {
181
+ collector.directive(block);
182
+ continue;
183
+ }
184
+ blocks.push({ block, origin });
185
+ }
186
+ }
187
+ }
188
+ return { blocks, suppressions : collector.finish(text.length) };
189
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Grammar heuristics. The linter has no part-of-speech tagger. These
3
+ * functions look at the words near a word to guess its function. Each
4
+ * heuristic prefers to miss a problem than to report a false one.
5
+ */
6
+
7
+ import { isCounted, isNumber, isWord, type Token } from "./tokens.js";
8
+ import {
9
+ BE_FORMS,
10
+ CLAUSE_STARTERS,
11
+ CONDITION_MARKERS,
12
+ CONJUNCTIONS,
13
+ DETERMINERS,
14
+ DO_FORMS,
15
+ HAVE_FORMS,
16
+ IRREGULAR_PARTICIPLES,
17
+ LEADING_ADVERBS,
18
+ MIDDLE_ADVERBS,
19
+ MODALS,
20
+ NOT_PARTICIPLE_ED,
21
+ OBJECT_PRONOUNS,
22
+ PARTICLES,
23
+ PASSIVE_AUXILIARIES,
24
+ PREPOSITIONS,
25
+ SUBJECT_PRONOUNS,
26
+ } from "./words.js";
27
+
28
+ /**
29
+ * The possible base forms of a word: the word itself, and the word without
30
+ * the endings -s, -es, -ies, -ed, -d, -ied and -ing. The list can contain
31
+ * forms that are not words. The callers look up each form in a word list.
32
+ */
33
+ export function lemmas(word : string) : string[] {
34
+ const forms = new Set<string>([word]);
35
+ const add = (form : string) : void => {
36
+ if (form.length >= 2) forms.add(form);
37
+ };
38
+ if (word.endsWith("'s")) add(word.slice(0, -2));
39
+ if (word.endsWith("ies")) add(word.slice(0, -3) + "y");
40
+ if (word.endsWith("es")) add(word.slice(0, -2));
41
+ if (word.endsWith("s") && !word.endsWith("ss")) add(word.slice(0, -1));
42
+ if (word.endsWith("ied")) add(word.slice(0, -3) + "y");
43
+ if (word.endsWith("ed")) {
44
+ const stem = word.slice(0, -2);
45
+ add(stem);
46
+ add(stem + "e");
47
+ if (/([^aeiou])\1$/.test(stem)) add(stem.slice(0, -1));
48
+ }
49
+ if (word.endsWith("ing")) {
50
+ const stem = word.slice(0, -3);
51
+ add(stem);
52
+ add(stem + "e");
53
+ if (/([^aeiou])\1$/.test(stem)) add(stem.slice(0, -1));
54
+ if (stem.endsWith("y")) add(stem.slice(0, -1) + "ie");
55
+ }
56
+ return [...forms];
57
+ }
58
+
59
+ /** True when one of the base forms of `word` is in `words`. */
60
+ export function hasLemmaIn(word : string, words : ReadonlySet<string>) : boolean {
61
+ return lemmas(word).some((form) => words.has(form));
62
+ }
63
+
64
+ /** True when `word` can be a past participle: an irregular participle, or a word that ends in -ed. */
65
+ export function isParticiple(word : string) : boolean {
66
+ if (IRREGULAR_PARTICIPLES.has(word)) return true;
67
+ return /^[a-z]{2,}ed$/.test(word) && !NOT_PARTICIPLE_ED.has(word);
68
+ }
69
+
70
+ /** True for a word that is an auxiliary verb: a form of "be", "have" or "do", or a modal verb. */
71
+ export function isAuxiliary(token : Token | undefined) : boolean {
72
+ return isWord(token) && (BE_FORMS.has(token.lower) || HAVE_FORMS.has(token.lower) || DO_FORMS.has(token.lower) || MODALS.has(token.lower));
73
+ }
74
+
75
+ /** The index of the next token that is a word or a placeholder, or -1. */
76
+ export function nextCounted(tokens : readonly Token[], from : number) : number {
77
+ for (let i = from; i < tokens.length; i++) if (isCounted(tokens[i])) return i;
78
+ return -1;
79
+ }
80
+
81
+ /** The previous token, or undefined at the start. */
82
+ function before(tokens : readonly Token[], index : number) : Token | undefined {
83
+ return tokens[index - 1];
84
+ }
85
+
86
+ /** True when the token can start the object of a verb: a determiner, a pronoun, a number, a placeholder or an adverb. */
87
+ export function startsObject(token : Token | undefined) : boolean {
88
+ if (token === undefined) return false;
89
+ if (token.kind === "code") return true;
90
+ if (!isWord(token)) return false;
91
+ return DETERMINERS.has(token.lower)
92
+ || OBJECT_PRONOUNS.has(token.lower)
93
+ || isNumber(token)
94
+ || token.lower.endsWith("ly")
95
+ || ["whether", "if", "how", "what", "when", "where", "which", "why", "who"].includes(token.lower);
96
+ }
97
+
98
+ export type ImperativeOptions = {
99
+ /** Base forms of verbs, for example the common verbs and the technical verbs of the glossary. */
100
+ readonly verbs : ReadonlySet<string>;
101
+ };
102
+
103
+ /**
104
+ * True when the sentence is an instruction (imperative). The heuristic:
105
+ *
106
+ * 1. Skip the adverbs at the start, for example "then", "first" and "please".
107
+ * 2. If the sentence starts with a condition or purpose clause ("If ...,",
108
+ * "To ...,", "When ...,"), continue after the first comma.
109
+ * 3. "Do not", "Never", "Make sure" and "Be sure" start an instruction.
110
+ * 4. Otherwise, the first word must be the base form of a known verb. The
111
+ * next words must not show that the first word is a noun subject. Thus,
112
+ * the next word is not an auxiliary verb or "of". Also, the next two
113
+ * words are not a plural noun and a verb ("Test runs take 5 s").
114
+ */
115
+ export function isInstruction(tokens : readonly Token[], options : ImperativeOptions) : boolean {
116
+ let k = nextCounted(tokens, 0);
117
+ if (k === -1) return false;
118
+ const skipAdverbs = () : void => {
119
+ while (k !== -1 && isWord(tokens[k]) && LEADING_ADVERBS.has(tokens[k]!.lower)) k = nextCounted(tokens, k + 1);
120
+ };
121
+ skipAdverbs();
122
+ if (k === -1) return false;
123
+ if (isWord(tokens[k]) && CLAUSE_STARTERS.has(tokens[k]!.lower)) {
124
+ const comma = tokens.findIndex((token, index) => index > k && token.kind === "punct" && token.text === ",");
125
+ if (comma === -1) return false;
126
+ k = nextCounted(tokens, comma + 1);
127
+ skipAdverbs();
128
+ if (k === -1) return false;
129
+ }
130
+ const word = tokens[k];
131
+ if (!isWord(word)) return false;
132
+ const n1 = tokens[k + 1];
133
+ const n2 = tokens[k + 2];
134
+ if (word.lower === "never" || word.lower === "don't") return true;
135
+ if (word.lower === "do" && isWord(n1) && n1.lower === "not") return true;
136
+ if (word.lower === "make" && isWord(n1) && n1.lower === "sure") return true;
137
+ if (word.lower === "be" && isWord(n1) && ["sure", "careful", "aware"].includes(n1.lower)) return true;
138
+ if (!options.verbs.has(word.lower)) return false;
139
+ if (isWord(n1)) {
140
+ if (isAuxiliary(n1) || n1.lower === "of") return false;
141
+ if (/[^s]s$/.test(n1.lower) && isWord(n2) && (isAuxiliary(n2) || options.verbs.has(n2.lower))) return false;
142
+ }
143
+ return true;
144
+ }
145
+
146
+ export type VerbUseOptions = {
147
+ readonly verbs : ReadonlySet<string>;
148
+ };
149
+
150
+ /**
151
+ * True when the word at `index` is likely a verb, not a noun or an
152
+ * adjective. The heuristic is conservative. It is true only when the words
153
+ * near the word show a verb. It looks at these words:
154
+ *
155
+ * - The word before. These words show a verb: "to", a modal verb, a form
156
+ * of "do", a subject pronoun, a form of "be" or "have" (for participles).
157
+ * - The start of the sentence, for an imperative
158
+ * - The word after, for an -s form: an object, for example "the", a
159
+ * pronoun, code or a number
160
+ * - A determiner before the word, which shows a noun ("the call", "a run").
161
+ */
162
+ export function isVerbUse(tokens : readonly Token[], index : number, options : VerbUseOptions) : boolean {
163
+ const word = tokens[index];
164
+ if (!isWord(word)) return false;
165
+ const previous = before(tokens, index);
166
+ const next = tokens[index + 1];
167
+ const lower = word.lower;
168
+ const previousLower = isWord(previous) ? previous.lower : null;
169
+
170
+ if (previousLower !== null && (DETERMINERS.has(previousLower) || isNumber(previous))) return false;
171
+
172
+ if (lower.endsWith("ing")) return isIngVerbUse(tokens, index);
173
+
174
+ if ((lower.endsWith("ed") || isParticiple(lower)) && previousLower !== null && (PASSIVE_AUXILIARIES.has(previousLower) || HAVE_FORMS.has(previousLower))) return true;
175
+ if (lower.endsWith("ed")) {
176
+ const subject = (previousLower !== null && SUBJECT_PRONOUNS.has(previousLower)) || previous?.kind === "code";
177
+ return subject && (startsObject(next) || (isWord(next) && PREPOSITIONS.has(next.lower)));
178
+ }
179
+ // An irregular participle such as "run" or "set" is also a base form, so the base form tests apply to it.
180
+
181
+ if (previousLower !== null) {
182
+ if (previousLower === "to" || MODALS.has(previousLower) || DO_FORMS.has(previousLower) || previousLower === "not") return true;
183
+ if (["you", "we", "they", "i", "please", "and", "or", "then", "also", "always", "first"].includes(previousLower)) return true;
184
+ }
185
+ // A clause start, or an adverb at a clause start: "Run the tests", "Simply run the tests".
186
+ const clauseStart = isClauseStart(tokens, index)
187
+ || (previousLower !== null && LEADING_ADVERBS.has(previousLower) && isClauseStart(tokens, index - 1));
188
+ if (clauseStart) {
189
+ // "Run time" can be a noun, so a plain word after the verb is not sufficient.
190
+ if (!options.verbs.has(lower)) return false;
191
+ return startsObject(next) || (isWord(next) && (PREPOSITIONS.has(next.lower) || PARTICLES.has(next.lower)));
192
+ }
193
+
194
+ if (/[^s]s$/.test(lower)) {
195
+ if (!lemmas(lower).some((form) => form !== lower && options.verbs.has(form))) return false;
196
+ // After "it", "which" or "that", an -s form is a verb: "it runs", "which calls".
197
+ if (previousLower !== null && SUBJECT_PRONOUNS.has(previousLower)) return true;
198
+ const subjectBefore = previous !== undefined && (previous.kind === "code" || (isWord(previous) && !PREPOSITIONS.has(previous.lower) && !CONJUNCTIONS.has(previous.lower)));
199
+ const objectAfter = startsObject(next) || (isWord(next) && (PREPOSITIONS.has(next.lower) || PARTICLES.has(next.lower)));
200
+ return subjectBefore && objectAfter;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /**
206
+ * True when the -ing word at `index` is a verb. The heuristic finds four
207
+ * uses:
208
+ *
209
+ * - After a form of "be": "is running", "is still running"
210
+ * - After a preposition, with an object: "by calling `stop()`", "after
211
+ * waiting for the worker"
212
+ * - At the start of a clause, with an object: "Using the worker, ..."
213
+ * - After a word that is not a determiner, with an object (a reduced
214
+ * clause): "a timer firing every 5 ms".
215
+ *
216
+ * A noun ("the timing") or an adjective before a noun ("existing code")
217
+ * is not a verb use.
218
+ */
219
+ export function isIngVerbUse(tokens : readonly Token[], index : number) : boolean {
220
+ const previous = tokens[index - 1];
221
+ const next = tokens[index + 1];
222
+ const objectAfter = startsObject(next) || (isWord(next) && (PREPOSITIONS.has(next.lower) || PARTICLES.has(next.lower)));
223
+ if (isWord(previous)) {
224
+ if (BE_FORMS.has(previous.lower)) return true;
225
+ const beforePrevious = tokens[index - 2];
226
+ if (MIDDLE_ADVERBS.has(previous.lower) && isWord(beforePrevious) && BE_FORMS.has(beforePrevious.lower)) return true;
227
+ if (PREPOSITIONS.has(previous.lower)) return objectAfter;
228
+ }
229
+ if (isClauseStart(tokens, index)) return startsObject(next);
230
+ if (isWord(previous) && !DETERMINERS.has(previous.lower) && !CONJUNCTIONS.has(previous.lower)) return startsObject(next);
231
+ return false;
232
+ }
233
+
234
+ /** True when the token is the first word of the sentence or the first word after a comma, colon or opening parenthesis. */
235
+ export function isClauseStart(tokens : readonly Token[], index : number) : boolean {
236
+ for (let i = index - 1; i >= 0; i--) {
237
+ const token = tokens[i]!;
238
+ if (isCounted(token)) return false;
239
+ if ([",", ":", "(", ";", "—", "–"].includes(token.text)) return true;
240
+ }
241
+ return true;
242
+ }
243
+
244
+ export type Passive = {
245
+ /** The index of the first word of the construction: the modal verb, "has" in "has to be", or the form of "be". */
246
+ readonly start : number;
247
+ /** The index of the form of "be" or "get". */
248
+ readonly auxiliary : number;
249
+ /** The index of the past participle. */
250
+ readonly participle : number;
251
+ /** True when a modal verb or "has to", "needs to" or "is to" shows an obligation: "must be set". */
252
+ readonly obligation : boolean;
253
+ /** True when a modal verb or "to" comes before "be": "can be set", "to be set". */
254
+ readonly modal : boolean;
255
+ /** True when "by" comes after the participle. */
256
+ readonly agent : boolean;
257
+ /** True when a condition word ("if", "when", "make sure that") comes before, in the same clause. */
258
+ readonly inCondition : boolean;
259
+ };
260
+
261
+ const OBLIGATION_MODALS = new Set(["must", "should", "shall", "mustn't", "shouldn't"]);
262
+
263
+ /** This function finds the passive constructions in a sentence: a form of "be" or "get" and a past participle. */
264
+ export function findPassives(tokens : readonly Token[]) : Passive[] {
265
+ const passives : Passive[] = [];
266
+ for (let i = 0; i < tokens.length; i++) {
267
+ const token = tokens[i]!;
268
+ if (!isWord(token) || !PASSIVE_AUXILIARIES.has(token.lower)) continue;
269
+ let j = i + 1;
270
+ let skipped = 0;
271
+ while (skipped < 2 && isWord(tokens[j]) && MIDDLE_ADVERBS.has(tokens[j]!.lower) && !isParticiple(tokens[j]!.lower)) {
272
+ j++;
273
+ skipped++;
274
+ }
275
+ const participle = tokens[j];
276
+ if (!isWord(participle) || !isParticiple(participle.lower)) continue;
277
+ const previous = isWord(tokens[i - 1]) ? tokens[i - 1]!.lower : null;
278
+ const previous2 = isWord(tokens[i - 2]) ? tokens[i - 2]!.lower : null;
279
+ const viaTo = previous === "to" && previous2 !== null && ["has", "have", "had", "need", "needs", "needed", "is", "are", "was", "were", "ought"].includes(previous2);
280
+ const obligation = (previous !== null && OBLIGATION_MODALS.has(previous)) || viaTo;
281
+ const modal = obligation || (previous !== null && (MODALS.has(previous) || previous === "to"));
282
+ const after = tokens[j + 1];
283
+ passives.push({
284
+ start : viaTo ? i - 2 : modal ? i - 1 : i,
285
+ auxiliary : i,
286
+ participle : j,
287
+ obligation,
288
+ modal,
289
+ agent : isWord(after) && after.lower === "by",
290
+ inCondition : hasConditionBefore(tokens, i),
291
+ });
292
+ i = j;
293
+ }
294
+ return passives;
295
+ }
296
+
297
+ function hasConditionBefore(tokens : readonly Token[], index : number) : boolean {
298
+ for (let i = index - 1; i >= 0; i--) {
299
+ const token = tokens[i]!;
300
+ if (token.kind === "punct" && [",", ";", ":"].includes(token.text)) return false;
301
+ if (isWord(token) && CONDITION_MARKERS.has(token.lower)) return true;
302
+ }
303
+ return false;
304
+ }
@@ -0,0 +1,72 @@
1
+ import { isWord, normalizeWord, type Token } from "./tokens.js";
2
+
3
+ export type PhraseMatcherOptions = {
4
+ /** Match the case of each letter. For example, the name "Event Timing" must not match "event timing". */
5
+ readonly caseSensitive? : boolean;
6
+ };
7
+
8
+ /**
9
+ * A set of words and multi-word phrases. A phrase matches consecutive word
10
+ * tokens, without case by default. Punctuation and placeholders between the
11
+ * words stop a match.
12
+ */
13
+ export class PhraseMatcher<T = true> {
14
+ private readonly byFirstWord = new Map<string, Array<{ readonly words : readonly string[]; readonly value : T }>>();
15
+ private readonly caseSensitive : boolean;
16
+
17
+ constructor(options : PhraseMatcherOptions = {}) {
18
+ this.caseSensitive = options.caseSensitive ?? false;
19
+ }
20
+
21
+ private key(text : string) : string {
22
+ return this.caseSensitive ? text.replace(/[’‘]/g, "'") : normalizeWord(text);
23
+ }
24
+
25
+ /** This method adds a phrase. A later phrase with the same words replaces the earlier one. */
26
+ add(phrase : string, value : T) : void {
27
+ const words = phrase.split(/\s+/).filter((word) => word.length > 0).map((word) => this.key(word));
28
+ if (words.length === 0) return;
29
+ const first = words[0]!;
30
+ const list = this.byFirstWord.get(first) ?? [];
31
+ const existing = list.findIndex((entry) => entry.words.join(" ") === words.join(" "));
32
+ if (existing >= 0) list.splice(existing, 1);
33
+ list.push({ words, value });
34
+ list.sort((a, b) => b.words.length - a.words.length);
35
+ this.byFirstWord.set(first, list);
36
+ }
37
+
38
+ /** The longest phrase that starts at `tokens[index]`, or null. */
39
+ match(tokens : readonly Token[], index : number) : { readonly length : number; readonly value : T } | null {
40
+ const first = tokens[index];
41
+ if (!isWord(first)) return null;
42
+ const candidates = this.byFirstWord.get(this.key(first.text));
43
+ if (candidates === undefined) return null;
44
+ for (const candidate of candidates) {
45
+ let matched = true;
46
+ for (let k = 1; k < candidate.words.length; k++) {
47
+ const token = tokens[index + k];
48
+ if (!isWord(token) || this.key(token.text) !== candidate.words[k]) {
49
+ matched = false;
50
+ break;
51
+ }
52
+ }
53
+ if (matched) return { length : candidate.words.length, value : candidate.value };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** The number of tokens in the longest phrase that starts at `tokens[index]`, or 0. */
59
+ longestMatch(tokens : readonly Token[], index : number) : number {
60
+ return this.match(tokens, index)?.length ?? 0;
61
+ }
62
+
63
+ /** True when the matcher has no phrases. */
64
+ get empty() : boolean {
65
+ return this.byFirstWord.size === 0;
66
+ }
67
+ }
68
+
69
+ /** The lower-case words of a phrase. */
70
+ export function splitPhrase(phrase : string) : string[] {
71
+ return normalizeWord(phrase).split(/\s+/).filter((word) => word.length > 0);
72
+ }