mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 02:48:50 +00:00
## Summary Implements the [URLPattern Web API](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) based on WebKit's implementation. URLPattern provides declarative pattern matching for URLs, similar to how regular expressions work for strings. ### Features - **Constructor**: Create patterns from strings or `URLPatternInit` dictionaries - **`test()`**: Check if a URL matches the pattern (returns boolean) - **`exec()`**: Extract matched groups from a URL (returns `URLPatternResult` or null) - **Pattern properties**: `protocol`, `username`, `password`, `hostname`, `port`, `pathname`, `search`, `hash` - **`hasRegExpGroups`**: Detect if the pattern uses custom regular expressions ### Example Usage ```js // Match URLs with a user ID parameter const pattern = new URLPattern({ pathname: '/users/:id' }); pattern.test('https://example.com/users/123'); // true pattern.test('https://example.com/posts/456'); // false const result = pattern.exec('https://example.com/users/123'); console.log(result.pathname.groups.id); // "123" // Wildcard matching const filesPattern = new URLPattern({ pathname: '/files/*' }); const match = filesPattern.exec('https://example.com/files/image.png'); console.log(match.pathname.groups[0]); // "image.png" ``` ## Implementation Notes - Adapted from WebKit's URLPattern implementation - Modified JS bindings to work with Bun's infrastructure (simpler `convertDictionary` patterns, WTF::Variant handling) - Added IsoSubspaces for proper GC integration ## Test Plan - [x] 408 tests from Web Platform Tests pass - [x] Tests fail with system Bun (URLPattern not defined), pass with debug build - [x] Manual testing of basic functionality Fixes https://github.com/oven-sh/bun/issues/2286 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
210 lines
7.4 KiB
TypeScript
210 lines
7.4 KiB
TypeScript
// Test data from Web Platform Tests
|
|
// https://github.com/web-platform-tests/wpt/blob/master/LICENSE.md
|
|
import { describe, expect, test } from "bun:test";
|
|
import testData from "./urlpatterntestdata.json";
|
|
|
|
const kComponents = ["protocol", "username", "password", "hostname", "port", "pathname", "search", "hash"] as const;
|
|
|
|
type Component = (typeof kComponents)[number];
|
|
|
|
interface TestEntry {
|
|
pattern: any[];
|
|
inputs?: any[];
|
|
expected_obj?: Record<string, string> | "error";
|
|
expected_match?: Record<string, any> | null | "error";
|
|
exactly_empty_components?: string[];
|
|
}
|
|
|
|
function getExpectedPatternString(entry: TestEntry, component: Component): string {
|
|
// If the test case explicitly provides an expected pattern string, use that
|
|
if (entry.expected_obj && typeof entry.expected_obj === "object" && entry.expected_obj[component] !== undefined) {
|
|
return entry.expected_obj[component];
|
|
}
|
|
|
|
// Determine if there is a baseURL present
|
|
let baseURL: URL | null = null;
|
|
if (entry.pattern.length > 0 && entry.pattern[0].baseURL) {
|
|
baseURL = new URL(entry.pattern[0].baseURL);
|
|
} else if (entry.pattern.length > 1 && typeof entry.pattern[1] === "string") {
|
|
baseURL = new URL(entry.pattern[1]);
|
|
}
|
|
|
|
const EARLIER_COMPONENTS: Record<Component, Component[]> = {
|
|
protocol: [],
|
|
hostname: ["protocol"],
|
|
port: ["protocol", "hostname"],
|
|
username: [],
|
|
password: [],
|
|
pathname: ["protocol", "hostname", "port"],
|
|
search: ["protocol", "hostname", "port", "pathname"],
|
|
hash: ["protocol", "hostname", "port", "pathname", "search"],
|
|
};
|
|
|
|
if (entry.exactly_empty_components?.includes(component)) {
|
|
return "";
|
|
} else if (typeof entry.pattern[0] === "object" && entry.pattern[0][component]) {
|
|
return entry.pattern[0][component];
|
|
} else if (typeof entry.pattern[0] === "object" && EARLIER_COMPONENTS[component].some(c => c in entry.pattern[0])) {
|
|
return "*";
|
|
} else if (baseURL && component !== "username" && component !== "password") {
|
|
let base_value = (baseURL as any)[component] as string;
|
|
if (component === "protocol") base_value = base_value.substring(0, base_value.length - 1);
|
|
else if (component === "search" || component === "hash") base_value = base_value.substring(1);
|
|
return base_value;
|
|
} else {
|
|
return "*";
|
|
}
|
|
}
|
|
|
|
function getExpectedComponentResult(
|
|
entry: TestEntry,
|
|
component: Component,
|
|
): { input: string; groups: Record<string, string | undefined> } {
|
|
let expected_obj = entry.expected_match?.[component];
|
|
|
|
if (!expected_obj) {
|
|
expected_obj = { input: "", groups: {} as Record<string, string | undefined> };
|
|
if (!entry.exactly_empty_components?.includes(component)) {
|
|
expected_obj.groups["0"] = "";
|
|
}
|
|
}
|
|
|
|
// Convert null to undefined in groups
|
|
for (const key in expected_obj.groups) {
|
|
if (expected_obj.groups[key] === null) {
|
|
expected_obj.groups[key] = undefined;
|
|
}
|
|
}
|
|
|
|
return expected_obj;
|
|
}
|
|
|
|
describe("URLPattern", () => {
|
|
describe("WPT tests", () => {
|
|
for (const entry of testData as TestEntry[]) {
|
|
const testName = `Pattern: ${JSON.stringify(entry.pattern)} Inputs: ${JSON.stringify(entry.inputs)}`;
|
|
|
|
test(testName, () => {
|
|
// Test construction error
|
|
if (entry.expected_obj === "error") {
|
|
expect(() => new URLPattern(...entry.pattern)).toThrow(TypeError);
|
|
return;
|
|
}
|
|
|
|
const pattern = new URLPattern(...entry.pattern);
|
|
|
|
// Verify compiled pattern properties
|
|
for (const component of kComponents) {
|
|
const expected = getExpectedPatternString(entry, component);
|
|
expect(pattern[component]).toBe(expected);
|
|
}
|
|
|
|
// Test match error
|
|
if (entry.expected_match === "error") {
|
|
expect(() => pattern.test(...(entry.inputs ?? []))).toThrow(TypeError);
|
|
expect(() => pattern.exec(...(entry.inputs ?? []))).toThrow(TypeError);
|
|
return;
|
|
}
|
|
|
|
// Test test() method
|
|
expect(pattern.test(...(entry.inputs ?? []))).toBe(!!entry.expected_match);
|
|
|
|
// Test exec() method
|
|
const exec_result = pattern.exec(...(entry.inputs ?? []));
|
|
|
|
if (!entry.expected_match || typeof entry.expected_match !== "object") {
|
|
expect(exec_result).toBe(entry.expected_match);
|
|
return;
|
|
}
|
|
|
|
const expected_inputs = entry.expected_match.inputs ?? entry.inputs;
|
|
|
|
// Verify inputs
|
|
expect(exec_result!.inputs.length).toBe(expected_inputs!.length);
|
|
for (let i = 0; i < exec_result!.inputs.length; i++) {
|
|
const input = exec_result!.inputs[i];
|
|
const expected_input = expected_inputs![i];
|
|
if (typeof input === "string") {
|
|
expect(input).toBe(expected_input);
|
|
} else {
|
|
for (const component of kComponents) {
|
|
expect(input[component]).toBe(expected_input[component]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify component results
|
|
for (const component of kComponents) {
|
|
const expected = getExpectedComponentResult(entry, component);
|
|
expect(exec_result![component]).toEqual(expected);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("constructor edge cases", () => {
|
|
test("unclosed token with URL object - %(", () => {
|
|
expect(() => new URLPattern(new URL("https://example.org/%("))).toThrow(TypeError);
|
|
});
|
|
|
|
test("unclosed token with URL object - %((", () => {
|
|
expect(() => new URLPattern(new URL("https://example.org/%(("))).toThrow(TypeError);
|
|
});
|
|
|
|
test("unclosed token with string - (\\", () => {
|
|
expect(() => new URLPattern("(\\")).toThrow(TypeError);
|
|
});
|
|
|
|
test("constructor with undefined arguments", () => {
|
|
// Should not throw
|
|
new URLPattern(undefined, undefined);
|
|
});
|
|
});
|
|
|
|
describe("hasRegExpGroups", () => {
|
|
test("match-everything pattern", () => {
|
|
expect(new URLPattern({}).hasRegExpGroups).toBe(false);
|
|
});
|
|
|
|
for (const component of kComponents) {
|
|
test(`wildcard in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: "*" }).hasRegExpGroups).toBe(false);
|
|
});
|
|
|
|
test(`segment wildcard in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: ":foo" }).hasRegExpGroups).toBe(false);
|
|
});
|
|
|
|
test(`optional segment wildcard in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: ":foo?" }).hasRegExpGroups).toBe(false);
|
|
});
|
|
|
|
test(`named regexp group in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: ":foo(hi)" }).hasRegExpGroups).toBe(true);
|
|
});
|
|
|
|
test(`anonymous regexp group in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: "(hi)" }).hasRegExpGroups).toBe(true);
|
|
});
|
|
|
|
if (component !== "protocol" && component !== "port") {
|
|
test(`wildcards mixed with fixed text in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: "a-{:hello}-z-*-a" }).hasRegExpGroups).toBe(false);
|
|
});
|
|
|
|
test(`regexp groups mixed with fixed text in ${component}`, () => {
|
|
expect(new URLPattern({ [component]: "a-(hi)-z-(lo)-a" }).hasRegExpGroups).toBe(true);
|
|
});
|
|
}
|
|
}
|
|
|
|
test("complex pathname with no regexp", () => {
|
|
expect(new URLPattern({ pathname: "/a/:foo/:baz?/b/*" }).hasRegExpGroups).toBe(false);
|
|
});
|
|
|
|
test("complex pathname with regexp", () => {
|
|
expect(new URLPattern({ pathname: "/a/:foo/:baz([a-z]+)?/b/*" }).hasRegExpGroups).toBe(true);
|
|
});
|
|
});
|
|
});
|