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/8] 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/8] 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; } } From 6ecd1f8e8bc0833c03f79fabe56dcfcd74d9a01a Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 27 Oct 2025 23:55:11 +0100 Subject: [PATCH 3/8] have same api than package.json --- utils/src/ast-grep/shebang.test.ts | 136 ++++++++++++++++++----------- utils/src/ast-grep/shebang.ts | 40 +++++---- 2 files changed, 108 insertions(+), 68 deletions(-) diff --git a/utils/src/ast-grep/shebang.test.ts b/utils/src/ast-grep/shebang.test.ts index b68ef9e0..259b071a 100644 --- a/utils/src/ast-grep/shebang.test.ts +++ b/utils/src/ast-grep/shebang.test.ts @@ -1,13 +1,12 @@ -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", () => { +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import astGrep from '@ast-grep/napi'; +import dedent from 'dedent'; +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!"); @@ -17,10 +16,10 @@ describe("shebang", () => { const shebang = getShebang(ast); assert.ok(shebang); - assert.equal(shebang.text(), "#!/usr/bin/env node"); + assert.equal(shebang.text(), '#!/usr/bin/env node'); }); - it("should take the last shebang line if multiple exist on top of the code", () => { + 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 @@ -30,10 +29,10 @@ describe("shebang", () => { const shebang = getShebang(ast); - assert.strictEqual(shebang?.text(), "#!/usr/bin/env node 2"); + assert.strictEqual(shebang?.text(), '#!/usr/bin/env node 2'); }); - it("should return null if no shebang line", () => { + it('should return null if no shebang line', () => { const code = dedent` console.log("Hello, world!"); `; @@ -69,107 +68,138 @@ describe("shebang", () => { }); }); - describe("replaceNodeJsArgs", () => { - it("should replace multiple different arguments in shebang with overlapping names", () => { + 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); + const edits = replaceNodeJsArgs(ast, { + '--foo': '--baz', + '--bar': '--qux', + }); 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'); + 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", () => { + 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); + const edits = replaceNodeJsArgs(ast, { + '--foo': '--baz', + '--bar': '--qux', + }); 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'); + 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", () => { + 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); + const edits = replaceNodeJsArgs(ast, { + '--foo': '--baz', + '--bar': '--qux', + }); 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'); + 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", () => { + 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); + const edits = replaceNodeJsArgs(ast, { '--foo': '--baz' }); // Should not replace because node must be present assert.strictEqual(edits.length, 0); }); - it("should replace argument with special characters", () => { + 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); + const edits = replaceNodeJsArgs(ast, { + '--foo-bar': '--baz-bar', + '--bar_foo': '--qux_foo', + }); 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'); + 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", () => { + 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); + const edits = replaceNodeJsArgs(ast, {}); assert.strictEqual(edits.length, 0); }); - it("should handle shebang with quoted arguments", () => { + 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); + const edits = replaceNodeJsArgs(ast, { + '"--foo"': '"--baz"', + "'--bar'": "'--qux'", + }); 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\''); + 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 index 6c880c69..16f676f1 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -1,4 +1,4 @@ -import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import type { SgRoot, Edit } from '@codemod.com/jssg-types/main'; /** * Get the shebang line from the root. @@ -6,21 +6,19 @@ import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; * @returns The shebang line if found, otherwise null. */ export const getShebang = (root: SgRoot) => - root - .root() - .find({ + root.root().find({ rule: { - kind: "hash_bang_line", - regex: "\\bnode(\\.exe)?\\b", + 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" - } - } - } - }) + kind: 'ERROR', + }, + }, + }, + }); /** * Replace Node.js arguments in the shebang line. @@ -29,16 +27,26 @@ export const getShebang = (root: SgRoot) => * @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[]) => { +/** + * Replace Node.js arguments in the shebang line and return edits. + * @param root The root node to search. + * @param argsToValues The mapping of argument names to their new values. + * @returns Array of edits to apply (empty if none). + */ +export const replaceNodeJsArgs = ( + root: SgRoot, + argsToValues: Record, +): Edit[] => { + const edits: Edit[] = []; const shebang = getShebang(root); - if (!shebang) return; + if (!shebang) return edits; const text = shebang.text(); // Find the "node" argument in the shebang const nodeMatch = text.match(/\bnode(\.exe)?\b/); - if (!nodeMatch) return; + if (!nodeMatch) return edits; // We only touch to something after node because before it's env thing const nodeIdx = nodeMatch.index! + nodeMatch[0].length; @@ -47,7 +55,7 @@ export const replaceNodeJsArgs = (root: SgRoot, argsToValues: Record Date: Sun, 28 Dec 2025 13:19:47 +0100 Subject: [PATCH 4/8] update Co-Authored-By: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com> --- utils/src/ast-grep/shebang.test.ts | 10 ++++++++-- utils/src/ast-grep/shebang.ts | 21 +++++++++------------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/utils/src/ast-grep/shebang.test.ts b/utils/src/ast-grep/shebang.test.ts index 259b071a..db7e2c34 100644 --- a/utils/src/ast-grep/shebang.test.ts +++ b/utils/src/ast-grep/shebang.test.ts @@ -15,8 +15,7 @@ describe('shebang', () => { const shebang = getShebang(ast); - assert.ok(shebang); - assert.equal(shebang.text(), '#!/usr/bin/env node'); + assert.equal(shebang?.text(), '#!/usr/bin/env node'); }); it('should take the last shebang line if multiple exist on top of the code', () => { @@ -40,6 +39,7 @@ describe('shebang', () => { const ast = astGrep.parse(astGrep.Lang.JavaScript, code); const shebang = getShebang(ast); + assert.strictEqual(shebang, null); }); @@ -153,6 +153,11 @@ describe('shebang', () => { console.log("Hello, world!"); `; const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + /** + * replace --foo-bar to --baz-bar + * replace --bar_foo to --qux_foo + */ const edits = replaceNodeJsArgs(ast, { '--foo-bar': '--baz-bar', '--bar_foo': '--qux_foo', @@ -175,6 +180,7 @@ describe('shebang', () => { console.log("Hello, world!"); `; const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + const edits = replaceNodeJsArgs(ast, {}); assert.strictEqual(edits.length, 0); diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts index 16f676f1..c229c3b3 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -1,5 +1,7 @@ import type { SgRoot, Edit } from '@codemod.com/jssg-types/main'; +const REGEX_ESCAPE_PATTERN = /[.*+?^${}()|[\]\\]/g; + /** * Get the shebang line from the root. * @param root The root node to search. @@ -11,7 +13,7 @@ export const getShebang = (root: SgRoot) => kind: 'hash_bang_line', regex: '\\bnode(\\.exe)?\\b', not: { - // tree-sitter wrap hash bang in Error node + tree-sitter wraps hash-bang in Error node // when it's not in the top of program node inside: { kind: 'ERROR', @@ -27,26 +29,21 @@ export const getShebang = (root: SgRoot) => * @param edits The list of edits to apply. * @returns The updated shebang line if any replacements were made, otherwise null. */ -/** - * Replace Node.js arguments in the shebang line and return edits. - * @param root The root node to search. - * @param argsToValues The mapping of argument names to their new values. - * @returns Array of edits to apply (empty if none). - */ export const replaceNodeJsArgs = ( root: SgRoot, argsToValues: Record, -): Edit[] => { - const edits: Edit[] = []; +) => { const shebang = getShebang(root); - if (!shebang) return edits; + if (!shebang) return []; + const edits: Edit[] = []; const text = shebang.text(); + // Find the "node" argument in the shebang const nodeMatch = text.match(/\bnode(\.exe)?\b/); - if (!nodeMatch) return edits; + if (!nodeMatch) return; // We only touch to something after node because before it's env thing const nodeIdx = nodeMatch.index! + nodeMatch[0].length; @@ -55,7 +52,7 @@ export const replaceNodeJsArgs = ( for (const argC of Object.keys(argsToValues)) { // Escape special regex characters in arg - const esc = argC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const esc = argC.replace(REGEX_ESCAPE_PATTERN, '\\$&'); const regex = new RegExp(`(\\s+)(["']?)${esc}(["']?)(?=\\s|$)`, 'g'); // handling quote and whitespaces From 50e59660c4ff64177b0eda87a8842c49fbb9c210 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:21:13 +0100 Subject: [PATCH 5/8] Update shebang.ts Co-Authored-By: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com> --- utils/src/ast-grep/shebang.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts index c229c3b3..4002e937 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -13,7 +13,7 @@ export const getShebang = (root: SgRoot) => kind: 'hash_bang_line', regex: '\\bnode(\\.exe)?\\b', not: { - tree-sitter wraps hash-bang in Error node + // tree-sitter wraps hash-bang in Error node // when it's not in the top of program node inside: { kind: 'ERROR', From cf13cc15a98ec2477d4f2554cbef9ea369c74a82 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:02:08 +0100 Subject: [PATCH 6/8] WIP --- utils/src/ast-grep/shebang.test.ts | 10 ++++---- utils/src/ast-grep/shebang.ts | 41 +++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/utils/src/ast-grep/shebang.test.ts b/utils/src/ast-grep/shebang.test.ts index db7e2c34..d6fa9c71 100644 --- a/utils/src/ast-grep/shebang.test.ts +++ b/utils/src/ast-grep/shebang.test.ts @@ -18,12 +18,12 @@ describe('shebang', () => { assert.equal(shebang?.text(), '#!/usr/bin/env node'); }); - it('should take the last shebang line if multiple exist on top of the code', () => { + it('should take the first 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!"); - `; + #!/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); diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts index 4002e937..f7beacfb 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -4,24 +4,47 @@ const REGEX_ESCAPE_PATTERN = /[.*+?^${}()|[\]\\]/g; /** * Get the shebang line from the root. + * According to ECMAScript spec, shebangs (InputElementHashbangOrRegExp) are only + * valid at the start of a Script or Module. We find hash_bang_lines that appear + * at the beginning before any actual code. When multiple consecutive shebangs exist at the top, + * we return the last one as it would be the effective shebang used. * @param root The root node to search. * @returns The shebang line if found, otherwise null. */ -export const getShebang = (root: SgRoot) => - root.root().find({ +export const getShebang = (root: SgRoot) => { + const allShebangs = root.root().findAll({ rule: { kind: 'hash_bang_line', regex: '\\bnode(\\.exe)?\\b', - not: { - // tree-sitter wraps hash-bang in Error node - // when it's not in the top of program node - inside: { - kind: 'ERROR', - }, - }, }, }); + if (!allShebangs.length) return null; + + // Check if first shebang is at line 0 (start of file) + const firstShebang = allShebangs[0]; + if (firstShebang.range().start.line !== 0) { + return null; // Shebang not at start of file + } + + // Collect all consecutive shebangs from the start + const validShebangs = [firstShebang]; + for (let i = 1; i < allShebangs.length; i++) { + const prevLine = allShebangs[i - 1].range().end.line; + const currentLine = allShebangs[i].range().start.line; + + // Check if this shebang is on the next consecutive line + if (currentLine === prevLine || currentLine === prevLine + 1) { + validShebangs.push(allShebangs[i]); + } else { + break; // Stop at first non-consecutive shebang + } + } + + // Return the last consecutive shebang from the start + return validShebangs[validShebangs.length - 1]; +}; + /** * Replace Node.js arguments in the shebang line. * @param root The root node to search. From a6131e7b4ddc6187591544b8383cbb2fd95971fb Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:06:29 +0100 Subject: [PATCH 7/8] Update shebang.ts --- utils/src/ast-grep/shebang.ts | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts index f7beacfb..7653d9fa 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -19,30 +19,21 @@ export const getShebang = (root: SgRoot) => { }, }); - if (!allShebangs.length) return null; + // Find the last consecutive shebang from the start of the file + let lastValidShebang = null; + let expectedLine = 0; - // Check if first shebang is at line 0 (start of file) - const firstShebang = allShebangs[0]; - if (firstShebang.range().start.line !== 0) { - return null; // Shebang not at start of file - } + for (const shebang of allShebangs) { + const range = shebang.range(); - // Collect all consecutive shebangs from the start - const validShebangs = [firstShebang]; - for (let i = 1; i < allShebangs.length; i++) { - const prevLine = allShebangs[i - 1].range().end.line; - const currentLine = allShebangs[i].range().start.line; - - // Check if this shebang is on the next consecutive line - if (currentLine === prevLine || currentLine === prevLine + 1) { - validShebangs.push(allShebangs[i]); - } else { - break; // Stop at first non-consecutive shebang - } + // Shebang must be at the expected line (0 for first, then consecutive) + if (range.start.line !== expectedLine) break; + + lastValidShebang = shebang; + expectedLine = range.end.line + 1; } - // Return the last consecutive shebang from the start - return validShebangs[validShebangs.length - 1]; + return lastValidShebang; }; /** From cb1a7595b682bfc7215e835ae6a9d9fdbf6c54fd Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:52:06 +0100 Subject: [PATCH 8/8] apply suggestion Co-Authored-By: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com> --- utils/src/ast-grep/shebang.test.ts | 16 ++++++++-------- utils/src/ast-grep/shebang.ts | 15 ++++++++------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/utils/src/ast-grep/shebang.test.ts b/utils/src/ast-grep/shebang.test.ts index d6fa9c71..030585c8 100644 --- a/utils/src/ast-grep/shebang.test.ts +++ b/utils/src/ast-grep/shebang.test.ts @@ -18,17 +18,17 @@ describe('shebang', () => { assert.equal(shebang?.text(), '#!/usr/bin/env node'); }); - it('should take the first shebang line if multiple exist on top of the code', () => { + it('should throw an error if multiple shebangs exist on top of the code', () => { const code = dedent` - #!/usr/bin/env node 1 - #!/usr/bin/env node 2 - console.log("Hello, world!"); - `; + #!/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'); + assert.throws(() => getShebang(ast), { + message: 'Multiple shebang lines found', + }); }); it('should return null if no shebang line', () => { diff --git a/utils/src/ast-grep/shebang.ts b/utils/src/ast-grep/shebang.ts index 7653d9fa..8af592ad 100644 --- a/utils/src/ast-grep/shebang.ts +++ b/utils/src/ast-grep/shebang.ts @@ -21,18 +21,19 @@ export const getShebang = (root: SgRoot) => { // Find the last consecutive shebang from the start of the file let lastValidShebang = null; - let expectedLine = 0; - for (const shebang of allShebangs) { - const range = shebang.range(); + if (allShebangs.length === 0) return null; - // Shebang must be at the expected line (0 for first, then consecutive) - if (range.start.line !== expectedLine) break; + const firstShebang = allShebangs[0]; - lastValidShebang = shebang; - expectedLine = range.end.line + 1; + if (firstShebang.range().start.line !== 0) return null; + + if (allShebangs.length > 1) { + throw new Error('Multiple shebang lines found'); } + lastValidShebang = firstShebang; + return lastValidShebang; };