Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
cdcdffe
fea(`node-url-to-whatwg-url`): scaffold codemod
AugustinMauroy Aug 3, 2025
3addfb3
url-parse(`node-url-to-whatwg-url`): setup `url-parse`
AugustinMauroy Aug 3, 2025
04174b8
WIP
AugustinMauroy Aug 3, 2025
4249d8e
url-parse(`node-url-to-whatwg-url`): introduce
AugustinMauroy Aug 12, 2025
873775a
clean
AugustinMauroy Aug 12, 2025
4cf1c36
WIP
AugustinMauroy Aug 12, 2025
ab18017
fix: ts
AugustinMauroy Aug 12, 2025
4b60d03
fix: windows
AugustinMauroy Aug 12, 2025
b289b2d
use resolve utility
AugustinMauroy Aug 12, 2025
50193e0
add more supported case for `url-format`
AugustinMauroy Aug 12, 2025
264a1f2
test: add more case
AugustinMauroy Aug 12, 2025
c60c073
add support of semicolon
AugustinMauroy Aug 12, 2025
c5ce549
Update package.json
AugustinMauroy Aug 12, 2025
cedf9bc
Revert "Update package.json"
AugustinMauroy Aug 12, 2025
0adc023
update
AugustinMauroy Aug 12, 2025
599fff4
fea(`node-url-to-whatwg-url`): scaffold codemod
AugustinMauroy Aug 3, 2025
a4ff1db
url-parse(`node-url-to-whatwg-url`): setup `url-parse`
AugustinMauroy Aug 3, 2025
4aeffdd
WIP
AugustinMauroy Aug 3, 2025
0b1f00a
url-parse(`node-url-to-whatwg-url`): introduce
AugustinMauroy Aug 12, 2025
729f5f8
clean
AugustinMauroy Aug 12, 2025
0914a53
WIP
AugustinMauroy Aug 12, 2025
d8debdf
fix: ts
AugustinMauroy Aug 12, 2025
b431aa4
fix: windows
AugustinMauroy Aug 12, 2025
25f7df2
use resolve utility
AugustinMauroy Aug 12, 2025
36f3b79
add more supported case for `url-format`
AugustinMauroy Aug 12, 2025
821e3ee
test: add more case
AugustinMauroy Aug 12, 2025
0713b1e
add support of semicolon
AugustinMauroy Aug 12, 2025
4b06973
Update package.json
AugustinMauroy Aug 12, 2025
9eb2c0d
Revert "Update package.json"
AugustinMauroy Aug 12, 2025
b893428
update
AugustinMauroy Aug 12, 2025
7c45240
Merge branch 'feat(`node-url-to-whatwg-url`)' of https://github.com/n…
AugustinMauroy Aug 12, 2025
3694442
update logic
AugustinMauroy Aug 12, 2025
e571ce7
Update url-format.ts
AugustinMauroy Aug 12, 2025
d240d5a
Update url-parse.ts
AugustinMauroy Aug 12, 2025
c128063
chore: remove `@next` and clean (#176)
AugustinMauroy Aug 12, 2025
9467f6c
fea(`node-url-to-whatwg-url`): scaffold codemod
AugustinMauroy Aug 3, 2025
4430f7f
url-parse(`node-url-to-whatwg-url`): setup `url-parse`
AugustinMauroy Aug 3, 2025
01247a7
WIP
AugustinMauroy Aug 3, 2025
e1b3415
url-parse(`node-url-to-whatwg-url`): introduce
AugustinMauroy Aug 12, 2025
68fe603
clean
AugustinMauroy Aug 12, 2025
1c6a884
WIP
AugustinMauroy Aug 12, 2025
ac7b540
use resolve utility
AugustinMauroy Aug 12, 2025
4bccf85
update
AugustinMauroy Aug 12, 2025
0e4799e
update logic
AugustinMauroy Aug 12, 2025
587e97d
Update url-format.ts
AugustinMauroy Aug 12, 2025
02fb501
Update url-parse.ts
AugustinMauroy Aug 12, 2025
245ca03
chore: remove `@next` and clean (#176)
AugustinMauroy Aug 12, 2025
6fdb711
Merge branch 'feat(`node-url-to-whatwg-url`)' of https://github.com/n…
AugustinMauroy Aug 13, 2025
81f421f
Update package-lock.json
AugustinMauroy Aug 13, 2025
841d576
Update url-format.ts
AugustinMauroy Aug 13, 2025
44ca92d
Merge branch 'main' into feat(`node-url-to-whatwg-url`)
AugustinMauroy Aug 17, 2025
c8ff01a
improve
AugustinMauroy Aug 17, 2025
ca7dadf
update from feedback
AugustinMauroy Aug 20, 2025
6601add
Update url-format.ts
AugustinMauroy Aug 20, 2025
328fadd
simplify
AugustinMauroy Aug 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions recipes/node-url-to-whatwg-url/src/import-process.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import type { SgRoot, Edit, Range } from "@codemod.com/jssg-types/main";
import type { SgRoot, SgNode, Edit, Range } from "@codemod.com/jssg-types/main";
import type JS from "@codemod.com/jssg-types/langs/javascript";
import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement";
import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call";
import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines";

const isBindingUsed = (rootNode: SgNode<JS>, name: string): boolean => {
const refs = rootNode.findAll({ rule: { pattern: name } });
// Heuristic: declaration counts as one; any other usage yields > 1
return refs.length > 1;
};

/**
* Clean up unused imports/requires from 'node:url' after transforms using shared utils
*/
export default function transform(root: SgRoot<JS>): string | null {
const rootNode = root.root();
const edits: Edit[] = [];

const isBindingUsed = (name: string): boolean => {
const refs = rootNode.findAll({ rule: { pattern: name } });
// Heuristic: declaration counts as one; any other usage yields > 1
return refs.length > 1;
};

const linesToRemove: Range[] = [];

// 1) ES Module imports: import ... from 'node:url'
Expand All @@ -28,7 +28,7 @@ export default function transform(root: SgRoot<JS>): string | null {
let removed = false;
if (clause) {
const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } });
if (nsId && !isBindingUsed(nsId.text())) {
if (nsId && !isBindingUsed(rootNode, nsId.text())) {
linesToRemove.push(imp.range());
removed = true;
}
Expand All @@ -38,7 +38,7 @@ export default function transform(root: SgRoot<JS>): string | null {

if (specs.length === 0 && !nsId) {
const defaultId = clause.find({ rule: { kind: "identifier" } });
if (defaultId && !isBindingUsed(defaultId.text())) {
if (defaultId && !isBindingUsed(rootNode, defaultId.text())) {
linesToRemove.push(imp.range());
removed = true;
}
Expand All @@ -50,7 +50,7 @@ export default function transform(root: SgRoot<JS>): string | null {
for (const spec of specs) {
const text = spec.text().trim();
const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text;
if (bindingName && isBindingUsed(bindingName)) keepTexts.push(text);
if (bindingName && isBindingUsed(rootNode, bindingName)) keepTexts.push(text);
}
if (keepTexts.length === 0) {
linesToRemove.push(imp.range());
Expand All @@ -71,7 +71,7 @@ export default function transform(root: SgRoot<JS>): string | null {
const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } });

if (id && !hasObjectPattern) {
if (!isBindingUsed(id.text())) linesToRemove.push(decl.parent().range());
if (!isBindingUsed(rootNode, id.text())) linesToRemove.push(decl.parent().range());
continue;
}

Expand All @@ -86,10 +86,10 @@ export default function transform(root: SgRoot<JS>): string | null {
}

const usedTexts: string[] = [];
for (const s of shorts) if (isBindingUsed(s.text())) usedTexts.push(s.text());
for (const s of shorts) if (isBindingUsed(rootNode, s.text())) usedTexts.push(s.text());
for (const pair of pairs) {
const aliasId = pair.find({ rule: { kind: "identifier" } });
if (aliasId && isBindingUsed(aliasId.text())) usedTexts.push(pair.text());
if (aliasId && isBindingUsed(rootNode, aliasId.text())) usedTexts.push(pair.text());
}

if (usedTexts.length === 0) {
Expand All @@ -105,10 +105,23 @@ export default function transform(root: SgRoot<JS>): string | null {

let source = rootNode.commitEdits(edits);

source = removeLines(source, linesToRemove.map(range => ({
start: { line: range.start.line, column: 0, index: 0 },
end: { line: range.end.line + 1, column: 0, index: 0 }
})));
// Only remove the next line if it is blank; don't delete non-empty following lines.
const srcLines = source.split("\n");
const adjustedRanges = linesToRemove.map((range) => {
const startLine = range.start.line;
let endLine = range.end.line;
const nextLine = endLine + 1;
if (nextLine < srcLines.length) {
const isNextLineBlank = /^\s*$/.test(srcLines[nextLine] ?? "");
if (isNextLineBlank) endLine = nextLine;
}
return {
start: { line: startLine, column: 0, index: 0 },
end: { line: endLine, column: 0, index: 0 },
} as Range;
});

source = removeLines(source, adjustedRanges);

return source;
};
154 changes: 54 additions & 100 deletions recipes/node-url-to-whatwg-url/src/url-format.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main";
import type JS from "@codemod.com/jssg-types/langs/javascript";
import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement";
import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call";
import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path";

type V = { literal: true; text: string } | { literal: false; code: string };

/**
* Get the literal text value of a node, if it exists.
* @param node The node to extract the literal text from
* @returns The literal text value, or undefined if not found
*/
const getLiteralText = (node: SgNode<JS> | null | undefined): string | undefined => {
const getLiteralText = (node: SgNode | null | undefined): string | undefined => {
if (!node) return undefined;
const kind = node.kind();

Expand All @@ -22,12 +23,40 @@ const getLiteralText = (node: SgNode<JS> | null | undefined): string | undefined
return undefined;
};

/**
* Get the value of a pair node.
* @param pair The pair node to extract the value from
* @returns The value of the pair node, or undefined if not found
*/
const getValue = (pair: SgNode): V | undefined => {
// string/number/bool
const litNode = pair.find({
rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] },
});
const lit = getLiteralText(litNode);
if (lit !== undefined) return { literal: true, text: lit };

// identifier value
const idNode = pair.find({ rule: { kind: "identifier" } });
if (idNode) return { literal: false, code: idNode.text() };

// shorthand property
const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } });
if (shorthand) return { literal: false, code: shorthand.text() };

// template string value
const template = pair.find({ rule: { kind: "template_string" } });
if (template) return { literal: false, code: template.text() };

return undefined;
};

/**
* Transforms url.format() calls to new URL().toString()
* @param callNode The AST nodes representing the url.format() calls
* @param edits The edits collector
*/
function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void {
for (const call of callNode) {
const optionsMatch = call.getMatch("OPTIONS");
if (!optionsMatch) continue;
Expand All @@ -36,7 +65,6 @@ function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
const objectNode = optionsMatch.find({ rule: { kind: "object" } });
if (!objectNode) continue;

type V = { literal: true; text: string } | { literal: false; code: string };
const urlState: {
protocol?: V;
auth?: V; // user:pass
Expand All @@ -49,29 +77,6 @@ function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
queryParams?: Array<[string, string]>;
} = {};

const getValue = (pair: SgNode<JS>): V | undefined => {
// string/number/bool
const litNode = pair.find({
rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] },
});
const lit = getLiteralText(litNode);
if (lit !== undefined) return { literal: true, text: lit };

// identifier value
const idNode = pair.find({ rule: { kind: "identifier" } });
if (idNode) return { literal: false, code: idNode.text() };

// shorthand property
const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } });
if (shorthand) return { literal: false, code: shorthand.text() };

// template string value
const template = pair.find({ rule: { kind: "template_string" } });
if (template) return { literal: false, code: template.text() };

return undefined;
};

const pairs = objectNode.findAll({ rule: { kind: "pair" } });

for (const pair of pairs) {
Expand All @@ -88,7 +93,14 @@ function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
for (const qp of qpairs) {
const qkeyNode = qp.find({ rule: { kind: "property_identifier" } });
const qvalLiteral = getLiteralText(
qp.find({ rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] } }),
qp.find({ rule:{
any: [
{ kind: "string" },
{ kind: "number" },
{ kind: "true" },
{ kind: "false" }
]
} }),
);
if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]);
}
Expand All @@ -101,43 +113,14 @@ function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
const val = getValue(pair);
if (!val) continue;

switch (key) {
case "protocol": {
if (val.literal) urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") };
else urlState.protocol = val;
break;
}
case "auth": {
urlState.auth = val;
break;
}
case "host": {
urlState.host = val;
break;
const handledProps = ["protocol", "auth", "host", "hostname", "port", "pathname", "search", "hash"];
if (handledProps.includes(key)) {
if (key === "protocol" && val.literal) {
urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") };
} else {
// biome-ignore lint/suspicious/noExplicitAny: IDK how to solve that
(urlState as any)[key] = val;
}
case "hostname": {
urlState.hostname = val;
break;
}
case "port": {
urlState.port = val;
break;
}
case "pathname": {
urlState.pathname = val;
break;
}
case "search": {
urlState.search = val;
break;
}
case "hash": {
urlState.hash = val;
break;
}
default:
// ignore unknown options in this simple mapping
break;
}
}

Expand All @@ -146,33 +129,10 @@ function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
for (const sh of shorthands) {
const name = sh.text();
const v: V = { literal: false, code: name };
switch (name) {
case "protocol":
urlState.protocol = v;
break;
case "auth":
urlState.auth = v;
break;
case "host":
urlState.host = v;
break;
case "hostname":
urlState.hostname = v;
break;
case "port":
urlState.port = v;
break;
case "pathname":
urlState.pathname = v;
break;
case "search":
urlState.search = v;
break;
case "hash":
urlState.hash = v;
break;
default:
break;
const handledProps = ['protocol', 'auth', 'host', 'hostname', 'port', 'pathname', 'search', 'hash'];
if (handledProps.includes(name)) {
// biome-ignore lint/suspicious/noExplicitAny: IDK how to solve that
(urlState as any)[name] = v;
}
}

Expand Down Expand Up @@ -278,30 +238,24 @@ function urlFormatToUrlToString(callNode: SgNode<JS>[], edits: Edit[]): void {
* 2. `foo.format(options)` → `new URL().toString()`
* 3. `foo(options)` → `new URL().toString()`
*/
export default function transform(root: SgRoot<JS>): string | null {
export default function transform(root: SgRoot): string | null {
const rootNode = root.root();
const edits: Edit[] = [];

// Safety: only run on files that import/require node:url
const importNodes = getNodeImportStatements(root as undefined, "url");
const requireNodes = getNodeRequireCalls(root as undefined, "url");
const importNodes = getNodeImportStatements(root, "url");
const requireNodes = getNodeRequireCalls(root, "url");
const requiresImports = [...importNodes, ...requireNodes]

if (!requiresImports.length) return null;

const parseCallPatterns = new Set<string>();

for (const node of requiresImports) {
// @ts-ignore - helper accepts ast-grep SgNode; runtime compatible
const binding = resolveBindingPath(node, "$.format");
if (binding) parseCallPatterns.add(`${binding}($OPTIONS)`);
}

// Fallbacks for common names and tests
["url.format($OPTIONS)", "nodeUrl.format($OPTIONS)", "format($OPTIONS)", "urlFormat($OPTIONS)"].forEach((p) =>
parseCallPatterns.add(p),
);

for (const pattern of parseCallPatterns) {
const calls = rootNode.findAll({ rule: { pattern } });

Expand Down
Loading
Loading