From 5531661383d9ba618577924e07eed768af8c172b Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 30 Aug 2025 10:39:21 +0200 Subject: [PATCH 1/2] feat(`utils`): add shebang --- utils/src/ast-grep/shebang.test.ts | 175 +++++++++++++++++++++++++++++ utils/src/ast-grep/shebang.ts | 63 +++++++++++ 2 files changed, 238 insertions(+) create mode 100644 utils/src/ast-grep/shebang.test.ts create mode 100644 utils/src/ast-grep/shebang.ts diff --git a/utils/src/ast-grep/shebang.test.ts b/utils/src/ast-grep/shebang.test.ts new file mode 100644 index 00000000..b68ef9e0 --- /dev/null +++ b/utils/src/ast-grep/shebang.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import astGrep from "@ast-grep/napi"; +import dedent from "dedent"; +import type { Edit } from "@ast-grep/napi"; +import { getShebang, replaceNodeJsArgs, } from './shebang.ts'; + +describe("shebang", () => { + describe("getShebang", () => { + it("should get the shebang line", () => { + const code = dedent` + #!/usr/bin/env node + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const shebang = getShebang(ast); + + assert.ok(shebang); + assert.equal(shebang.text(), "#!/usr/bin/env node"); + }); + + it("should take the last shebang line if multiple exist on top of the code", () => { + const code = dedent` + #!/usr/bin/env node 1 + #!/usr/bin/env node 2 + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const shebang = getShebang(ast); + + assert.strictEqual(shebang?.text(), "#!/usr/bin/env node 2"); + }); + + it("should return null if no shebang line", () => { + const code = dedent` + console.log("Hello, world!"); + `; + + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const shebang = getShebang(ast); + assert.strictEqual(shebang, null); + }); + + it("shouldn't catch shebangs in comments", () => { + const code = dedent` + // #!/usr/bin/env node + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const shebang = getShebang(ast); + + assert.strictEqual(shebang, null); + }); + + it("shouldn't catch shebang in middle of code", () => { + const code = dedent` + console.log("Hello, world!"); + #!/usr/bin/env node + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const shebang = getShebang(ast); + + assert.strictEqual(shebang, null); + }); + }); + + describe("replaceNodeJsArgs", () => { + it("should replace multiple different arguments in shebang with overlapping names", () => { + const code = dedent` + #!/usr/bin/env node --foo --foobar --bar + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, { '--foo': '--baz', '--bar': '--qux' }, edits); + + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].insertedText, '#!/usr/bin/env node --baz --foobar --bar'); + assert.strictEqual(edits[1].insertedText, '#!/usr/bin/env node --baz --foobar --qux'); + }); + + it("should not replace arguments that are substrings of other args", () => { + const code = dedent` + #!/usr/bin/env node --foo --foo-bar --bar + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, { '--foo': '--baz', '--bar': '--qux' }, edits); + + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].insertedText, '#!/usr/bin/env node --baz --foo-bar --bar'); + assert.strictEqual(edits[1].insertedText, '#!/usr/bin/env node --baz --foo-bar --qux'); + }); + + it("should handle shebang with multiple spaces between args", () => { + const code = dedent` + #!/usr/bin/env node --foo --bar + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, { '--foo': '--baz', '--bar': '--qux' }, edits); + + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].insertedText, '#!/usr/bin/env node --baz --bar'); + assert.strictEqual(edits[1].insertedText, '#!/usr/bin/env node --baz --qux'); + }); + + it("should not replace if argument is at the start of the shebang", () => { + const code = dedent` + #!/usr/bin/env --foo node --bar + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, { '--foo': '--baz' }, edits); + + // Should not replace because node must be present + assert.strictEqual(edits.length, 0); + }); + + it("should replace argument with special characters", () => { + const code = dedent` + #!/usr/bin/env node --foo-bar --bar_foo + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, { '--foo-bar': '--baz-bar', '--bar_foo': '--qux_foo' }, edits); + + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].insertedText, '#!/usr/bin/env node --baz-bar --bar_foo'); + assert.strictEqual(edits[1].insertedText, '#!/usr/bin/env node --baz-bar --qux_foo'); + }); + + it("should not replace anything if argsToValues is empty", () => { + const code = dedent` + #!/usr/bin/env node --foo --bar + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, {}, edits); + + assert.strictEqual(edits.length, 0); + }); + + it("should handle shebang with quoted arguments", () => { + const code = dedent` + #!/usr/bin/env node "--foo" '--bar' + console.log("Hello, world!"); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits: Edit[] = []; + + replaceNodeJsArgs(ast, { '"--foo"': '"--baz"', "'--bar'": "'--qux'" }, edits); + + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].insertedText, '#!/usr/bin/env node "--baz" \'--bar\''); + assert.strictEqual(edits[1].insertedText, '#!/usr/bin/env node "--baz" \'--qux\''); + }); + }); +}); diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts new file mode 100644 index 00000000..eacc355a --- /dev/null +++ b/utils/src/ast-grep/shebang.ts @@ -0,0 +1,63 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; + +/** + * Get the shebang line from the root. + * @param root The root node to search. + * @returns The shebang line if found, otherwise null. + */ +export const getShebang = (root: SgRoot) => + root + .root() + .find({ + rule: { + kind: "hash_bang_line", + regex: "\\bnode(\\.exe)?\\b", + not: { + // tree-sitter wrap hash bang in Error node + // when it's not in the top of program node + inside: { + kind: "ERROR" + } + } + } + }) + +/** + * Replace Node.js arguments in the shebang line. + * @param root The root node to search. + * @param argsToValues The mapping of argument names to their new values. + * @param edits The list of edits to apply. + * @returns The updated shebang line if any replacements were made, otherwise null. + */ +export const replaceNodeJsArgs = (root: SgRoot, argsToValues: Record, edits: Edit[]) => { + const shebang = getShebang(root); + if (!shebang) return; + + const text = shebang.text(); + const nodeMatch = text.match(/\bnode(\.exe)?\b/); + + if (!nodeMatch) return; + + const nodeIdx = nodeMatch.index! + nodeMatch[0].length; + const beforeNode = text.slice(0, nodeIdx); + let afterNode = text.slice(nodeIdx); + + const sortedArgs = Object.keys(argsToValues); + + for (const argC of sortedArgs) { + // Escape special regex characters in arg + const esc = argC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(\\s+)(["']?)${esc}(["']?)(?=\\s|$)`, 'g'); + let replaced = false; + const newAfterNode = afterNode.replace(regex, (_unused, ws, q1, q2) => { + replaced = true; + const replacement = argsToValues[argC]; + return `${ws}${q1}${replacement}${q2}`; + }); + if (replaced && newAfterNode !== afterNode) { + const newText = beforeNode + newAfterNode; + edits.push(shebang.replace(newText)); + afterNode = newAfterNode; + } + } +}; From 52d32771b27a41efb933a41a0269a5a75b92d158 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 30 Aug 2025 10:46:01 +0200 Subject: [PATCH 2/2] feat(`utils`): clean shebang --- utils/src/ast-grep/shebang.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts index eacc355a..6c880c69 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -31,32 +31,34 @@ export const getShebang = (root: SgRoot) => */ export const replaceNodeJsArgs = (root: SgRoot, argsToValues: Record, edits: Edit[]) => { const shebang = getShebang(root); + if (!shebang) return; const text = shebang.text(); + // Find the "node" argument in the shebang const nodeMatch = text.match(/\bnode(\.exe)?\b/); if (!nodeMatch) return; + // We only touch to something after node because before it's env thing const nodeIdx = nodeMatch.index! + nodeMatch[0].length; const beforeNode = text.slice(0, nodeIdx); let afterNode = text.slice(nodeIdx); - const sortedArgs = Object.keys(argsToValues); - - for (const argC of sortedArgs) { + for (const argC of Object.keys(argsToValues)) { // Escape special regex characters in arg const esc = argC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`(\\s+)(["']?)${esc}(["']?)(?=\\s|$)`, 'g'); - let replaced = false; + + // handling quote and whitespaces const newAfterNode = afterNode.replace(regex, (_unused, ws, q1, q2) => { - replaced = true; const replacement = argsToValues[argC]; + return `${ws}${q1}${replacement}${q2}`; }); - if (replaced && newAfterNode !== afterNode) { - const newText = beforeNode + newAfterNode; - edits.push(shebang.replace(newText)); + + if (newAfterNode !== afterNode) { + edits.push(shebang.replace(beforeNode + newAfterNode)); afterNode = newAfterNode; } }