@alint-js/languages 0.1.5
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 +85 -0
- package/dist/index.d.mts +1036 -0
- package/dist/index.mjs +288 -0
- package/package.json +31 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { sliceRange, targetIdentity, withLanguage, withStableIdentities } from "@alint-js/core";
|
|
3
|
+
import Parser from "web-tree-sitter";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
//#region ../plugin/dist/index.mjs
|
|
6
|
+
function definePlugin(plugin) {
|
|
7
|
+
return plugin;
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/grammar.ts
|
|
11
|
+
const grammarDir = join(dirname(createRequire(import.meta.url).resolve("tree-sitter-wasms/package.json")), "out");
|
|
12
|
+
/**
|
|
13
|
+
* Which languages this package provides, and the grammar each is parsed with. Adding a language
|
|
14
|
+
* starts here: `LanguageId` is derived from these keys, and `QUERIES` must then cover it too.
|
|
15
|
+
*
|
|
16
|
+
* Ids are spelled as editors spell them, per the convention on `LanguageDefinition.name`.
|
|
17
|
+
* TypeScript and JavaScript are deliberately missing — core's oxc producer already owns those
|
|
18
|
+
* extensions, and `registerLanguage` throws when two plugins claim the same one.
|
|
19
|
+
*/
|
|
20
|
+
const GRAMMAR = {
|
|
21
|
+
go: "tree-sitter-go.wasm",
|
|
22
|
+
python: "tree-sitter-python.wasm",
|
|
23
|
+
rust: "tree-sitter-rust.wasm"
|
|
24
|
+
};
|
|
25
|
+
let ready;
|
|
26
|
+
const grammars = /* @__PURE__ */ new Map();
|
|
27
|
+
async function grammarFor(language) {
|
|
28
|
+
ready ??= Parser.init();
|
|
29
|
+
await ready;
|
|
30
|
+
let grammar = grammars.get(language);
|
|
31
|
+
if (grammar === void 0) {
|
|
32
|
+
grammar = await Parser.Language.load(join(grammarDir, GRAMMAR[language]));
|
|
33
|
+
grammars.set(language, grammar);
|
|
34
|
+
}
|
|
35
|
+
return grammar;
|
|
36
|
+
}
|
|
37
|
+
function isLanguageId(value) {
|
|
38
|
+
return Object.hasOwn(GRAMMAR, value);
|
|
39
|
+
}
|
|
40
|
+
const QUERIES = {
|
|
41
|
+
go: `
|
|
42
|
+
(function_declaration) @function
|
|
43
|
+
(method_declaration) @function
|
|
44
|
+
(comment) @comment
|
|
45
|
+
(call_expression function: (identifier) @call)
|
|
46
|
+
(call_expression function: (selector_expression field: (field_identifier) @call))
|
|
47
|
+
(identifier) @identifier
|
|
48
|
+
|
|
49
|
+
(function_declaration name: (identifier) @binder)
|
|
50
|
+
(parameter_declaration name: (identifier) @binder)
|
|
51
|
+
(variadic_parameter_declaration name: (identifier) @binder)
|
|
52
|
+
(short_var_declaration left: (expression_list (identifier) @binder))
|
|
53
|
+
(var_spec name: (identifier) @binder)
|
|
54
|
+
(const_spec name: (identifier) @binder)
|
|
55
|
+
(range_clause left: (expression_list (identifier) @binder))
|
|
56
|
+
`,
|
|
57
|
+
python: `
|
|
58
|
+
(function_definition) @function
|
|
59
|
+
(comment) @comment
|
|
60
|
+
(function_definition body: (block . (expression_statement (string) @comment)))
|
|
61
|
+
(call function: (identifier) @call)
|
|
62
|
+
(call function: (attribute attribute: (identifier) @call))
|
|
63
|
+
(identifier) @identifier
|
|
64
|
+
(attribute attribute: (identifier) @anchor)
|
|
65
|
+
(keyword_argument name: (identifier) @anchor)
|
|
66
|
+
|
|
67
|
+
(function_definition name: (identifier) @binder)
|
|
68
|
+
(parameters (identifier) @binder)
|
|
69
|
+
(default_parameter name: (identifier) @binder)
|
|
70
|
+
(typed_parameter (identifier) @binder)
|
|
71
|
+
(typed_default_parameter name: (identifier) @binder)
|
|
72
|
+
(assignment left: (identifier) @binder)
|
|
73
|
+
(for_statement left: (identifier) @binder)
|
|
74
|
+
(lambda_parameters (identifier) @binder)
|
|
75
|
+
`,
|
|
76
|
+
rust: `
|
|
77
|
+
(function_item) @function
|
|
78
|
+
(line_comment) @comment
|
|
79
|
+
(block_comment) @comment
|
|
80
|
+
(call_expression function: (identifier) @call)
|
|
81
|
+
(call_expression function: (field_expression field: (field_identifier) @call))
|
|
82
|
+
(call_expression function: (scoped_identifier name: (identifier) @call))
|
|
83
|
+
(identifier) @identifier
|
|
84
|
+
|
|
85
|
+
(function_item name: (identifier) @binder)
|
|
86
|
+
(parameter pattern: (identifier) @binder)
|
|
87
|
+
(let_declaration pattern: (identifier) @binder)
|
|
88
|
+
(closure_parameters (identifier) @binder)
|
|
89
|
+
`
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/extract.ts
|
|
93
|
+
const queries = /* @__PURE__ */ new Map();
|
|
94
|
+
/**
|
|
95
|
+
* One file target carrying every call site, then one function target per function, each with the
|
|
96
|
+
* `FunctionInfo` a consumer would otherwise need its own parser to work out.
|
|
97
|
+
*
|
|
98
|
+
* The language comes from the file rather than a second argument, because every target carries that
|
|
99
|
+
* same file and the two could then disagree. Pass the file through `withLanguage` first.
|
|
100
|
+
*/
|
|
101
|
+
async function extractTargets(file) {
|
|
102
|
+
if (!isLanguageId(file.language)) throw new Error(`Language "${file.language}" is not provided by @alint-js/languages.`);
|
|
103
|
+
const language = file.language;
|
|
104
|
+
const grammar = await grammarFor(language);
|
|
105
|
+
const parser = new Parser();
|
|
106
|
+
parser.setLanguage(grammar);
|
|
107
|
+
const tree = parser.parse(file.text);
|
|
108
|
+
let query = queries.get(language);
|
|
109
|
+
if (query === void 0) {
|
|
110
|
+
query = grammar.query(QUERIES[language]);
|
|
111
|
+
queries.set(language, query);
|
|
112
|
+
}
|
|
113
|
+
const calls = [];
|
|
114
|
+
const comments = [];
|
|
115
|
+
const identifiers = [];
|
|
116
|
+
const anchored = /* @__PURE__ */ new Set();
|
|
117
|
+
const binders = [];
|
|
118
|
+
const functionNodes = [];
|
|
119
|
+
for (const { name, node } of query.captures(tree.rootNode)) switch (name) {
|
|
120
|
+
case "anchor":
|
|
121
|
+
anchored.add(node.startIndex);
|
|
122
|
+
break;
|
|
123
|
+
case "binder":
|
|
124
|
+
binders.push(node);
|
|
125
|
+
break;
|
|
126
|
+
case "call":
|
|
127
|
+
calls.push({
|
|
128
|
+
name: node.text,
|
|
129
|
+
range: rangeOf(node)
|
|
130
|
+
});
|
|
131
|
+
break;
|
|
132
|
+
case "comment":
|
|
133
|
+
comments.push(rangeOf(node));
|
|
134
|
+
break;
|
|
135
|
+
case "function":
|
|
136
|
+
functionNodes.push(node);
|
|
137
|
+
break;
|
|
138
|
+
case "identifier":
|
|
139
|
+
identifiers.push(rangeOf(node));
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
const renameable = identifiers.filter((range) => !anchored.has(range.start));
|
|
143
|
+
const commentStarts = new Set(comments.map((range) => range.start));
|
|
144
|
+
const functionTargets = functionNodes.map((node) => functionTarget(node, file, language, comments, commentStarts, renameable, binders));
|
|
145
|
+
return [fileTarget(file, calls), ...withStableIdentities(functionTargets)];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The body's statements, comments left out.
|
|
149
|
+
*
|
|
150
|
+
* All three grammars make a comment a named child of the block it sits in, so comments have to be
|
|
151
|
+
* filtered out here. Leave them in and adding a comment to a one-line function makes it look two
|
|
152
|
+
* statements long.
|
|
153
|
+
*/
|
|
154
|
+
function bodyStatements(node, commentStarts) {
|
|
155
|
+
const body = node.childForFieldName("body");
|
|
156
|
+
if (body === null) return [];
|
|
157
|
+
const statements = [];
|
|
158
|
+
for (let index = 0; index < body.namedChildCount; index += 1) {
|
|
159
|
+
const child = body.namedChild(index);
|
|
160
|
+
if (child !== null && !commentStarts.has(child.startIndex)) statements.push(child);
|
|
161
|
+
}
|
|
162
|
+
return statements;
|
|
163
|
+
}
|
|
164
|
+
function fileTarget(file, calls) {
|
|
165
|
+
return {
|
|
166
|
+
file,
|
|
167
|
+
identity: "file",
|
|
168
|
+
kind: "file",
|
|
169
|
+
language: file.language,
|
|
170
|
+
metadata: { calls },
|
|
171
|
+
origin: { physicalPath: file.path },
|
|
172
|
+
text: file.text
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function functionTarget(node, file, language, comments, commentStarts, identifiers, binders) {
|
|
176
|
+
const range = rangeOf(node);
|
|
177
|
+
const source = sliceRange(file, range);
|
|
178
|
+
const nameNode = node.childForFieldName("name");
|
|
179
|
+
const name = nameNode?.text;
|
|
180
|
+
const statements = bodyStatements(node, commentStarts);
|
|
181
|
+
const exported = isExported(node, language, name ?? "");
|
|
182
|
+
const info = {
|
|
183
|
+
bodyIsSingleExpression: statements.length === 1 && !holdsBlock(statements[0]),
|
|
184
|
+
bodyStatements: statements.length,
|
|
185
|
+
commentRanges: rangesInside(comments, range),
|
|
186
|
+
declaredNames: [.../* @__PURE__ */ new Set([...binders.filter((binder) => binder.startIndex >= range.start && binder.endIndex <= range.end).map((binder) => binder.text), ...name === void 0 ? [] : [name]])],
|
|
187
|
+
exported,
|
|
188
|
+
identifierRanges: withOwnName(rangesInside(identifiers, range), nameNode, range)
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
file,
|
|
192
|
+
identity: targetIdentity("function", name, range),
|
|
193
|
+
kind: "function",
|
|
194
|
+
language: file.language,
|
|
195
|
+
loc: source.loc,
|
|
196
|
+
metadata: {
|
|
197
|
+
exported,
|
|
198
|
+
function: info
|
|
199
|
+
},
|
|
200
|
+
name,
|
|
201
|
+
origin: {
|
|
202
|
+
physicalPath: file.path,
|
|
203
|
+
range
|
|
204
|
+
},
|
|
205
|
+
range,
|
|
206
|
+
text: source.text
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Whether anything under this node is a block, which is how all three grammars write a branch or a loop. */
|
|
210
|
+
function holdsBlock(node) {
|
|
211
|
+
const pending = [...node.namedChildren];
|
|
212
|
+
while (pending.length > 0) {
|
|
213
|
+
const next = pending.pop();
|
|
214
|
+
if (next === void 0) continue;
|
|
215
|
+
if (next.type === "block") return true;
|
|
216
|
+
pending.push(...next.namedChildren);
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
/** Reachable from outside its file. Go says it with a capital, Python with the absence of a leading underscore, Rust with `pub`. */
|
|
221
|
+
function isExported(node, language, name) {
|
|
222
|
+
switch (language) {
|
|
223
|
+
case "go": return /^[A-Z]/.test(name);
|
|
224
|
+
case "python": return !name.startsWith("_");
|
|
225
|
+
case "rust": return node.children.some((child) => child.type === "visibility_modifier");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** `web-tree-sitter` counts in JavaScript string indexes, not UTF-8 bytes, so `text.slice(start, end)` is correct. */
|
|
229
|
+
function rangeOf(node) {
|
|
230
|
+
return {
|
|
231
|
+
end: node.endIndex,
|
|
232
|
+
start: node.startIndex
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Rebases the ranges inside `outer` onto the function's own `text`, which is where `FunctionInfo` says they point. */
|
|
236
|
+
function rangesInside(ranges, outer) {
|
|
237
|
+
return ranges.filter((range) => range.start >= outer.start && range.end <= outer.end).map((range) => ({
|
|
238
|
+
end: range.end - outer.start,
|
|
239
|
+
start: range.start - outer.start
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Adds the function's own name to its renameable identifiers.
|
|
244
|
+
*
|
|
245
|
+
* This only has anything to add for a Go method, whose name is a `field_identifier` rather than an
|
|
246
|
+
* `identifier`. The Go query leaves `field_identifier` out on purpose, because reading a struct
|
|
247
|
+
* field is not renameable, but a method naming itself is. Every other name the queries capture is
|
|
248
|
+
* already an `@identifier`, so the dedupe by start offset drops the second copy.
|
|
249
|
+
*/
|
|
250
|
+
function withOwnName(ranges, nameNode, outer) {
|
|
251
|
+
if (nameNode === null) return ranges;
|
|
252
|
+
const name = {
|
|
253
|
+
end: nameNode.endIndex - outer.start,
|
|
254
|
+
start: nameNode.startIndex - outer.start
|
|
255
|
+
};
|
|
256
|
+
if (ranges.some((range) => range.start === name.start)) return ranges;
|
|
257
|
+
return [...ranges, name].sort((left, right) => left.start - right.start);
|
|
258
|
+
}
|
|
259
|
+
//#endregion
|
|
260
|
+
//#region src/index.ts
|
|
261
|
+
const goLanguage = {
|
|
262
|
+
extensions: [".go"],
|
|
263
|
+
extract: (file) => extractTargets(withLanguage(file, "go")),
|
|
264
|
+
name: "go"
|
|
265
|
+
};
|
|
266
|
+
const pythonLanguage = {
|
|
267
|
+
extensions: [".py"],
|
|
268
|
+
extract: (file) => extractTargets(withLanguage(file, "python")),
|
|
269
|
+
name: "python"
|
|
270
|
+
};
|
|
271
|
+
const rustLanguage = {
|
|
272
|
+
extensions: [".rs"],
|
|
273
|
+
extract: (file) => extractTargets(withLanguage(file, "rust")),
|
|
274
|
+
name: "rust"
|
|
275
|
+
};
|
|
276
|
+
/**
|
|
277
|
+
* A plugin that declares languages and nothing else: no rules, no processors.
|
|
278
|
+
*
|
|
279
|
+
* The alias a config registers it under does not matter, because languages register under their own
|
|
280
|
+
* names. Once registered, they serve every plugin's rules, not just this one's.
|
|
281
|
+
*/
|
|
282
|
+
const languagesPlugin = definePlugin({ languages: {
|
|
283
|
+
go: goLanguage,
|
|
284
|
+
python: pythonLanguage,
|
|
285
|
+
rust: rustLanguage
|
|
286
|
+
} });
|
|
287
|
+
//#endregion
|
|
288
|
+
export { languagesPlugin as default, languagesPlugin, extractTargets, goLanguage, pythonLanguage, rustLanguage };
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alint-js/languages",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.5",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.mts",
|
|
8
|
+
"default": "./dist/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@alint-js/core": "0.1.5"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"tree-sitter-wasms": "^0.1.13",
|
|
20
|
+
"web-tree-sitter": "^0.24.7"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@alint-js/core": "0.1.5",
|
|
24
|
+
"@alint-js/plugin": "0.1.5"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"test": "vitest run --config vitest.config.ts"
|
|
30
|
+
}
|
|
31
|
+
}
|