Skip to content

Commit 00deddb

Browse files
feat(utils): add package.json
1 parent b1e9a9d commit 00deddb

File tree

4 files changed

+274
-0
lines changed

4 files changed

+274
-0
lines changed

package-lock.json

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"license": "MIT",
1515
"type": "module",
1616
"devDependencies": {
17+
"@ast-grep/lang-json": "^0.0.4",
1718
"@ast-grep/napi": "^0.39.3",
1819
"@codemod.com/jssg-types": "^1.0.3",
1920
"dedent": "^1.6.0"
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import dedent from "dedent";
4+
import jsonLang from "@ast-grep/lang-json";
5+
import { registerDynamicLanguage, parse } from "@ast-grep/napi";
6+
import type { Edit } from "@ast-grep/napi";
7+
import { getScriptsNode, getNodeJsUsage, replaceNodeJsArgs } from "./package-json.ts";
8+
9+
registerDynamicLanguage({ json: jsonLang });
10+
11+
describe("package-json utilities", () => {
12+
describe("getScriptsNode", () => {
13+
it("should get the scripts node", () => {
14+
const input = dedent`
15+
{
16+
"scripts": {
17+
"test": "echo \\"Error: no test specified\\" && exit 1"
18+
}
19+
}
20+
`;
21+
22+
const result = getScriptsNode(parse('json', input));
23+
24+
assert(result);
25+
assert.strictEqual(result.children().length, 3); // curly braces + pair + curly braces
26+
});
27+
28+
it("should return empty array if any scripts is present", () => {
29+
const input = dedent`
30+
{
31+
"name": "example-package",
32+
"version": "1.0.0"
33+
}
34+
`;
35+
36+
const result = getNodeJsUsage(parse('json', input));
37+
38+
assert.strictEqual(result.length, 0);
39+
});
40+
41+
it("should throw an error if multiple scripts nodes are found", () => {
42+
const input = dedent`
43+
{
44+
"scripts": {
45+
"test": "echo \\"Error: no test specified\\" && exit 1"
46+
},
47+
"scripts": {
48+
"start": "node index.js"
49+
}
50+
}
51+
`;
52+
53+
assert.throws(() => getScriptsNode(parse('json', input)), {
54+
message: /Multiple "scripts" fields found/
55+
});
56+
});
57+
});
58+
59+
describe("getNodeJsUsage", () => {
60+
it("should get Node.js usage in scripts", () => {
61+
const input = dedent`
62+
{
63+
"scripts": {
64+
"start": "node script.js",
65+
"test": "echo \\"Error: no test specified\\" && exit 1"
66+
}
67+
}
68+
`;
69+
70+
const result = getNodeJsUsage(parse('json', input));
71+
72+
assert.strictEqual(result.length, 1);
73+
assert.strictEqual(result[0].text(), "node script.js");
74+
});
75+
76+
it("should not catch `node_modules`", () => {
77+
const input = dedent`
78+
{
79+
"scripts": {
80+
"start": "node_modules/.bin/some-tool",
81+
"test": "node another-script.js"
82+
}
83+
}
84+
`;
85+
86+
const result = getNodeJsUsage(parse('json', input));
87+
88+
assert.strictEqual(result.length, 1);
89+
assert.strictEqual(result[0].text(), "node another-script.js");
90+
});
91+
92+
it("should return empty array if no Node.js usage is found", () => {
93+
const input = dedent`
94+
{
95+
"scripts": {
96+
"start": "npm run build",
97+
"test": "echo \\"Error: no test specified\\" && exit 1"
98+
}
99+
}
100+
`;
101+
102+
const result = getNodeJsUsage(parse('json', input));
103+
104+
assert.strictEqual(result.length, 0);
105+
});
106+
});
107+
108+
describe("replaceNodeJsArgs", () => {
109+
it("should replace Node.js arguments in scripts", () => {
110+
const input = dedent`
111+
{
112+
"scripts": {
113+
"start": "node --experimental-foo script.js",
114+
}
115+
}
116+
`;
117+
118+
const edits: Edit[] = [];
119+
replaceNodeJsArgs(parse('json', input), { '--experimental-foo': '--experimental-bar' }, edits);
120+
121+
assert.strictEqual(edits.length, 1);
122+
assert.strictEqual(edits[0].insertedText, 'node --experimental-bar script.js');
123+
});
124+
125+
it("should not replace an arg that contains same", () => {
126+
const input = dedent`
127+
{
128+
"scripts": {
129+
"start": "node --experimental-foo-prop script.js",
130+
}
131+
}
132+
`;
133+
134+
const edits: Edit[] = [];
135+
replaceNodeJsArgs(parse('json', input), { '--experimental-foo': '--experimental-bar' }, edits);
136+
137+
assert.strictEqual(edits.length, 0);
138+
});
139+
140+
141+
it("should handle multiple replacements", () => {
142+
const input = dedent`
143+
{
144+
"scripts": {
145+
"start": "node --experimental-foo script.js",
146+
"test": "node --experimental-foo script.js"
147+
}
148+
}
149+
`;
150+
151+
const edits: Edit[] = [];
152+
replaceNodeJsArgs(parse('json', input), { '--experimental-foo': '--experimental-bar' }, edits);
153+
154+
assert.strictEqual(edits.length, 2);
155+
assert.strictEqual(edits[0].insertedText, 'node --experimental-bar script.js');
156+
assert.strictEqual(edits[1].insertedText, 'node --experimental-bar script.js');
157+
});
158+
});
159+
});

utils/src/ast-grep/package-json.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { SgRoot, Edit } from "@codemod.com/jssg-types/main";
2+
3+
/**
4+
* Get the "scripts" node from a package.json AST.
5+
* @param packageJsonRootNode The root node of the package.json AST.
6+
* @returns The "scripts" node, or null if not found.
7+
*/
8+
export const getScriptsNode = (packageJsonRootNode: SgRoot) => {
9+
const scriptsNodes = packageJsonRootNode
10+
.root()
11+
.findAll({
12+
rule: {
13+
kind: "pair",
14+
has: {
15+
field: "key",
16+
kind: "string",
17+
has: {
18+
kind: "string_content",
19+
regex: "scripts"
20+
}
21+
}
22+
}
23+
});
24+
25+
if (scriptsNodes.length > 1)
26+
throw new Error(`Multiple "scripts" fields found in ${packageJsonRootNode.filename()}`);
27+
28+
return scriptsNodes[0] ?? null;
29+
};
30+
31+
/**
32+
* Get all usage of Node.js in the "scripts" node of a package.json AST.
33+
* @param packageJsonRootNode The root node of the package.json AST.
34+
* @returns An array of nodes representing the usage of Node.js.
35+
*/
36+
export const getNodeJsUsage = (packageJsonRootNode: SgRoot) => {
37+
const scriptsNode = getScriptsNode(packageJsonRootNode);
38+
39+
if (!scriptsNode) return [];
40+
41+
return scriptsNode
42+
.findAll({
43+
rule: {
44+
kind: "string_content",
45+
regex: "\\bnode(\\.exe)?\\b",
46+
inside: {
47+
kind: "string",
48+
inside: {
49+
kind: "pair",
50+
}
51+
}
52+
}
53+
});
54+
};
55+
56+
/**
57+
* Replace Node.js arguments in the "scripts" node of a package.json AST.
58+
* @param packageJsonRootNode The root node of the package.json AST.
59+
* @param argsToValues A record mapping arguments to their replacement values.
60+
* @param edits An array to collect the edits made.
61+
*/
62+
export const replaceNodeJsArgs = (packageJsonRootNode: SgRoot, argsToValues: Record<string, string>, edits: Edit[]) => {
63+
const nodeJsUsageNodes = getNodeJsUsage(packageJsonRootNode);
64+
65+
if (!nodeJsUsageNodes.length) return;
66+
67+
for (const nodeJsUsageNode of nodeJsUsageNodes) {
68+
const text = nodeJsUsageNode.text();
69+
70+
for (const [argC, argP] of Object.entries(argsToValues)) {
71+
const regex = new RegExp(`(?<!\\S)${argC}(?!\\S)`, 'g'); // Match standalone arguments
72+
if (regex.test(text)) {
73+
const newText = text.replace(regex, argP);
74+
edits.push(nodeJsUsageNode.replace(newText));
75+
}
76+
}
77+
}
78+
};

0 commit comments

Comments
 (0)