Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 101 additions & 0 deletions packages/cloudflare/src/cli/build/patches/ast/optional-deps.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";

import { optionalDepRule } from "./optional-deps.js";
import { patchCode } from "./util.js";

describe("optional dependecy", () => {
it('should wrap a top-level require("caniuse-lite") in a try-catch', () => {
const code = `t = require("caniuse-lite");`;
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
"try {
t = require("caniuse-lite");
} catch {
throw new Error('The optional dependency "caniuse-lite" is not installed');
};"
`);
});

it('should wrap a top-level require("caniuse-lite/data") in a try-catch', () => {
const code = `t = require("caniuse-lite/data");`;
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(
`
"try {
t = require("caniuse-lite/data");
} catch {
throw new Error('The optional dependency "caniuse-lite/data" is not installed');
};"
`
);
});

it('should wrap e.exports = require("caniuse-lite") in a try-catch', () => {
const code = 'e.exports = require("caniuse-lite");';
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
"try {
e.exports = require("caniuse-lite");
} catch {
throw new Error('The optional dependency "caniuse-lite" is not installed');
};"
`);
});

it('should wrap module.exports = require("caniuse-lite") in a try-catch', () => {
const code = 'module.exports = require("caniuse-lite");';
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
"try {
module.exports = require("caniuse-lite");
} catch {
throw new Error('The optional dependency "caniuse-lite" is not installed');
};"
`);
});

it('should wrap exports.foo = require("caniuse-lite") in a try-catch', () => {
const code = 'exports.foo = require("caniuse-lite");';
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
"try {
exports.foo = require("caniuse-lite");
} catch {
throw new Error('The optional dependency "caniuse-lite" is not installed');
};"
`);
});

it('should not wrap require("lodash") in a try-catch', () => {
const code = 't = require("lodash");';
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`"t = require("lodash");"`);
});

it('should not wrap require("other-module") if it does not match caniuse-lite regex', () => {
const code = 't = require("other-module");';
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`"t = require("other-module");"`);
});

it("should not wrap a require() call already inside a try-catch", () => {
const code = `
try {
const t = require("caniuse-lite");
} catch {}
`;
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
"try {
const t = require("caniuse-lite");
} catch {}
"
`);
});

it("should handle require with subpath and not wrap if already in try-catch", () => {
const code = `
try {
const t = require("caniuse-lite/path");
} catch {}
`;
expect(patchCode(code, optionalDepRule)).toMatchInlineSnapshot(`
"try {
const t = require("caniuse-lite/path");
} catch {}
"
`);
});
});
16 changes: 5 additions & 11 deletions packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
import { type SgNode } from "@ast-grep/napi";

import { applyRule } from "./util.js";
import { getRuleEdits } from "./util.js";

/**
* Handle optional dependencies.
*
* A top level `require(dep)` would throw when the dep is not installed.
* A top level `require(optionalDep)` would throw when the dep is not installed.
*
* So we wrap any of
* - `t = require("dep")`
* - `t = require("dep/sub/path")`
* - `t = require("dep/sub/path/" + var)`
* - `e.exports = require("dep")`
*
* in a try/catch (only if not already).
* So we wrap `require(optionalDep)` in a try/catch (if not already present).
*/
const rule = `
export const optionalDepRule = `
rule:
pattern: $$$LHS = require($$$REQ)
has:
Expand All @@ -37,5 +31,5 @@ fix: |-
`;

export function patchOptionalDependencies(root: SgNode) {
return applyRule(rule, root);
return getRuleEdits(optionalDepRule, root);
}
65 changes: 65 additions & 0 deletions packages/cloudflare/src/cli/build/patches/ast/util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { patchCode } from "./util.js";

describe("patchCode", () => {
afterEach(() => {
vi.clearAllMocks();
});

it("should throw an error if rule has a transform", () => {
expect(() =>
patchCode(`console.log("hi")`, { rule: { pattern: "console.log($MSG)" }, transform: "not supported" })
).toThrow(/not supported/);
});

it("should throw an error if rule has no fix", () => {
expect(() => patchCode(`console.log("hi")`, { rule: { pattern: "console.log($MSG)" } })).toThrow(
/no fix/
);
});

it("should accept yaml rules", () => {
const yamlRule = `
rule:
pattern: a
fix: b
`;

expect(patchCode(`a`, yamlRule)).toEqual("b");
});

it("should apply fix to a single match when once is true", () => {
expect(patchCode(`a+a`, { rule: { pattern: "a" }, fix: "b" }, { once: true })).toEqual("b+a");
});

it("should apply fix to all matches when once is false (default)", () => {
expect(patchCode(`a+a`, { rule: { pattern: "a" }, fix: "b" })).toEqual("b+b");
expect(patchCode(`a+a`, { rule: { pattern: "a" }, fix: "b" }, { once: false })).toEqual("b+b");
});

it("should handle no matches", () => {
expect(patchCode(`a`, { rule: { pattern: "b" }, fix: "c" })).toEqual("a");
});

it("should replace $PLACEHOLDER with match text", () => {
expect(
patchCode(`console.log(message)`, { rule: { pattern: "console.log($MSG)" }, fix: "$MSG" })
).toEqual("message");
});

it("should handle $PLACEHODLERS that are not found in matches", () => {
expect(
patchCode(`console.log(message)`, { rule: { pattern: "console.log($MSG)" }, fix: "$WHAT$$$WHAT" })
).toEqual("$WHAT");
});

it("should replace $$$PLACEHOLDER with match text", () => {
expect(
patchCode(`console.log("hello" + world, "!")`, {
rule: { pattern: "console.log($$$ARGS)" },
fix: "$$$ARGS",
})
).toEqual(`"hello" + world,"!"`);
});
});
44 changes: 36 additions & 8 deletions packages/cloudflare/src/cli/build/patches/ast/util.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
import { type Edit, type NapiConfig, type SgNode } from "@ast-grep/napi";
import { type Edit, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
import yaml from "yaml";

/**
* fix has the same meaning as in yaml rules
* see https://ast-grep.github.io/guide/rewrite-code.html#using-fix-in-yaml-rule
*/
export type RuleConfig = NapiConfig & { fix?: string };

/**
* Returns the `Edit`s for an ast-grep rule in yaml format
*
* The rule must have a `fix` to rewrite the matched node.
*
* Tip: use https://ast-grep.github.io/playground.html to create rules.
*
* @param yamlRule The rule in yaml format
* @param rule The rule. Either a yaml string or an instance of `RuleConfig`
* @param root The root node
* @param once only apply once
* @returns A list of edits.
*/
export function applyRule(yamlRule: string, root: SgNode, { once = false } = {}) {
const rule: NapiConfig & { fix?: string } = yaml.parse(yamlRule);
if (rule.transform) {
export function getRuleEdits(rule: string | RuleConfig, root: SgNode, { once = false } = {}) {
const ruleConfig: RuleConfig = typeof rule === "string" ? yaml.parse(rule) : rule;
if (ruleConfig.transform) {
throw new Error("transform is not supported");
}
if (!rule.fix) {
if (!ruleConfig.fix) {
throw new Error("no fix to apply");
}

const fix = rule.fix;
const fix = ruleConfig.fix;

const matches = once ? [root.find(rule)].filter((m) => m !== null) : root.findAll(rule);
const matches = once ? [root.find(ruleConfig)].filter((m) => m !== null) : root.findAll(ruleConfig);

const edits: Edit[] = [];

Expand All @@ -46,3 +52,25 @@ export function applyRule(yamlRule: string, root: SgNode, { once = false } = {})

return edits;
}

/**
* Patches the code from by applying the rule.
*
* This function is mainly for on off edits and tests,
* use `getRuleEdits` to apply multiple rules.
*
* @param code The source code
* @param rule The astgrep rule (yaml or NapiConfig)
* @param lang The language used by the source code
* @param lang Whether to apply the rule only once
* @returns The patched code
*/
export function patchCode(
code: string,
rule: string | RuleConfig,
{ lang = Lang.TypeScript, once = false } = {}
): string {
const node = parse(lang, code).root();
const edits = getRuleEdits(rule, node, { once });
return node.commitEdits(edits);
}
Loading