From 1452aadc8741fc873ca57b528ecdae61fe4c9aee Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:29:34 -0500 Subject: [PATCH] feat(codemode): add substr, well-formed, and Date string methods (#48275) --- packages/codemode/interpreter-support.md | 6 +- packages/codemode/src/interpreter/methods.ts | 11 ++ packages/codemode/src/stdlib/date.ts | 6 + packages/codemode/src/stdlib/string.ts | 5 + packages/codemode/test/parity.test.ts | 8 +- .../test/string-date-methods-test262.test.ts | 179 ++++++++++++++++++ 6 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 packages/codemode/test/string-date-methods-test262.test.ts diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 65d637e8db9..9cfa27cc63e 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -250,12 +250,13 @@ ultimate source of truth. ## Strings - [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`. -- [x] Trimming: `trim`, `trimStart`, and `trimEnd`. +- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases. - [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`. -- [x] Slicing/access: `slice`, `substring`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. +- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`. - [x] Construction/transformation: `split`, `concat`, `repeat`, `padStart`, `padEnd`, `replace`, and `replaceAll`. - [x] Regular-expression integration: `match`, materialized `matchAll`, `replace`, `replaceAll`, `split`, and `search`. - [x] `localeCompare`; locale and options arguments are currently ignored. +- [x] `isWellFormed` and `toWellFormed`. - [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point. - [x] Static `String.fromCharCode` and `String.fromCodePoint`. - [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like @@ -316,6 +317,7 @@ ultimate source of truth. - [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and `TimeClip` behavior. - [x] `Date.prototype.toUTCString` and its `toGMTString` alias. +- [x] `toDateString` and `toTimeString` in the host's local timezone. - [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects. - [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string representation for the string primitive. diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index dcf142a0e3d..5d0c4bdbb41 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -118,9 +118,11 @@ const invokeStringMethod = (value: string, name: string, args: Array, n result = value.trim() break case "trimStart": + case "trimLeft": result = value.trimStart() break case "trimEnd": + case "trimRight": result = value.trimEnd() break // Locale/options are deliberately unsupported; comparison uses the host default locale. @@ -241,6 +243,15 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)) break + case "substr": + result = value.substr(optNum(0) ?? 0, optNum(1)) + break + case "isWellFormed": + result = value.isWellFormed() + break + case "toWellFormed": + result = value.toWellFormed() + break case "charCodeAt": result = value.charCodeAt(optNum(0) ?? 0) break diff --git a/packages/codemode/src/stdlib/date.ts b/packages/codemode/src/stdlib/date.ts index 026b8a18857..307f0d2da95 100644 --- a/packages/codemode/src/stdlib/date.ts +++ b/packages/codemode/src/stdlib/date.ts @@ -29,6 +29,8 @@ export const dateMethods = new Set([ "toISOString", "toJSON", "toString", + "toDateString", + "toTimeString", "toUTCString", "toGMTString", "getFullYear", @@ -103,6 +105,10 @@ export const invokeDateMethod = ( return Number.isFinite(value.time) ? hosted.toISOString() : null case "toString": return coerceToString(value) + case "toDateString": + return hosted.toDateString() + case "toTimeString": + return hosted.toTimeString() case "toUTCString": case "toGMTString": return hosted.toUTCString() diff --git a/packages/codemode/src/stdlib/string.ts b/packages/codemode/src/stdlib/string.ts index 355a254187a..a22361eeda1 100644 --- a/packages/codemode/src/stdlib/string.ts +++ b/packages/codemode/src/stdlib/string.ts @@ -8,9 +8,12 @@ export const stringMethods = new Set([ "trim", "trimStart", "trimEnd", + "trimLeft", + "trimRight", "split", "slice", "substring", + "substr", "includes", "startsWith", "endsWith", @@ -32,6 +35,8 @@ export const stringMethods = new Set([ "search", "localeCompare", "normalize", + "isWellFormed", + "toWellFormed", ]) const codeUnits = (name: string, op: (...codes: Array) => string) => diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 4257f1e89e1..7c4c657e72e 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -491,12 +491,8 @@ describe("CodeMode-specific string behavior", () => { expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"') }) - test("does not expose obsolete string aliases", async () => { - expect(await value(`return [typeof "x".trimLeft, typeof "x".trimRight, typeof "x".substr]`)).toEqual([ - "undefined", - "undefined", - "undefined", - ]) + test("exposes the Annex B string aliases every engine ships", async () => { + expect(await value(`return [" x ".trimLeft(), " x ".trimRight(), "abc".substr(1, 1)]`)).toEqual(["x ", " x", "b"]) }) }) diff --git a/packages/codemode/test/string-date-methods-test262.test.ts b/packages/codemode/test/string-date-methods-test262.test.ts new file mode 100644 index 00000000000..41757052756 --- /dev/null +++ b/packages/codemode/test/string-date-methods-test262.test.ts @@ -0,0 +1,179 @@ +/** + * Portions adapted from Test262 at revision 250f204f23a9249ff204be2baec29600faae7b75: + * - test/annexB/built-ins/String/prototype/substr/length-falsey.js + * - test/annexB/built-ins/String/prototype/substr/length-negative.js + * - test/annexB/built-ins/String/prototype/substr/length-positive.js + * - test/annexB/built-ins/String/prototype/substr/length-undef.js + * - test/annexB/built-ins/String/prototype/substr/start-negative.js + * - test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js + * - test/built-ins/String/prototype/isWellFormed/returns-boolean.js + * - test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js + * - test/built-ins/Date/prototype/toDateString/format.js + * - test/built-ins/Date/prototype/toDateString/invalid-date.js + * - test/built-ins/Date/prototype/toDateString/negative-year.js + * - test/built-ins/Date/prototype/toTimeString/format.js + * - test/built-ins/Date/prototype/toTimeString/invalid-date.js + * + * Copyright (C) 2016, 2017 the V8 project authors. All rights reserved. + * Copyright (C) 2018 Richard Gibson. All rights reserved. + * Copyright (C) 2022 Jordan Harband. All rights reserved. + * Test262 portions are governed by the BSD license in LICENSE.test262. + * + * The `typeof String.prototype.method` checks are replaced with `typeof "".method` because + * CodeMode has no prototype objects. + */ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" + +const value = async (code: string) => { + const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} })) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +describe("String.prototype.substr Test262 parity", () => { + test("test/annexB/built-ins/String/prototype/substr/length-falsey.js", async () => { + expect( + await value(` + return [false, NaN, "", null].flatMap((length) => [0, 1, 2, 3].map((start) => "abc".substr(start, length))) + `), + ).toEqual(Array(16).fill("")) + }) + + test("test/annexB/built-ins/String/prototype/substr/length-negative.js", async () => { + expect( + await value(` + return [0, 1, 2, 3].flatMap((start) => [-1, -2, -3, -4].map((length) => "abc".substr(start, length))) + `), + ).toEqual(Array(16).fill("")) + }) + + test("test/annexB/built-ins/String/prototype/substr/length-positive.js", async () => { + expect( + await value(` + return [0, 1, 2, 3].map((start) => [1, 2, 3, 4].map((length) => "abc".substr(start, length))) + `), + ).toEqual([ + ["a", "ab", "abc", "abc"], + ["b", "bc", "bc", "bc"], + ["c", "c", "c", "c"], + ["", "", "", ""], + ]) + }) + + test("test/annexB/built-ins/String/prototype/substr/length-undef.js", async () => { + expect( + await value(` + return [ + "abc".substr(0), "abc".substr(1), "abc".substr(2), "abc".substr(3), + "abc".substr(0, undefined), "abc".substr(1, undefined), "abc".substr(2, undefined), "abc".substr(3, undefined), + ] + `), + ).toEqual(["abc", "bc", "c", "", "abc", "bc", "c", ""]) + }) + + test("test/annexB/built-ins/String/prototype/substr/start-negative.js", async () => { + expect( + await value(` + return ["abc".substr(-1), "abc".substr(-2), "abc".substr(-3), "abc".substr(-4), "abc".substr(-1.1)] + `), + ).toEqual(["c", "bc", "abc", "abc", "c"]) + }) + + test("test/annexB/built-ins/String/prototype/substr/surrogate-pairs.js", async () => { + expect( + await value(` + const pair = "\\ud834\\udf06" + return [pair.substr(0), pair.substr(1), pair.substr(2), pair.substr(0, 0), pair.substr(0, 1), pair.substr(0, 2)] + `), + ).toEqual(["\ud834\udf06", "\udf06", "", "", "\ud834", "\ud834\udf06"]) + }) +}) + +describe("String well-formedness Test262 parity", () => { + test("test/built-ins/String/prototype/isWellFormed/returns-boolean.js", async () => { + expect( + await value(` + const leadingPoo = "\\uD83D" + const trailingPoo = "\\uDCA9" + const wholePoo = leadingPoo + trailingPoo + return [ + typeof "".isWellFormed, + ("a" + leadingPoo + "c" + leadingPoo + "e").isWellFormed(), + ("a" + trailingPoo + "c" + trailingPoo + "e").isWellFormed(), + ("a" + trailingPoo + leadingPoo + "d").isWellFormed(), + "a💩c".isWellFormed(), + "a\\uD83D\\uDCA9c".isWellFormed(), + ("a" + leadingPoo + trailingPoo + "d").isWellFormed(), + wholePoo.slice(0, 1).isWellFormed(), + wholePoo.slice(1).isWellFormed(), + "abc".isWellFormed(), + "a\\u25A8c".isWellFormed(), + ] + `), + ).toEqual(["function", false, false, false, true, true, true, false, false, true, true]) + }) + + test("test/built-ins/String/prototype/toWellFormed/returns-well-formed-string.js", async () => { + expect( + await value(` + const replacementChar = "\\uFFFD" + const leadingPoo = "\\uD83D" + const trailingPoo = "\\uDCA9" + const wholePoo = leadingPoo + trailingPoo + return [ + typeof "".toWellFormed, + ("a" + leadingPoo + "c" + leadingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e", + ("a" + trailingPoo + "c" + trailingPoo + "e").toWellFormed() === "a" + replacementChar + "c" + replacementChar + "e", + ("a" + trailingPoo + leadingPoo + "d").toWellFormed() === "a" + replacementChar + replacementChar + "d", + "a💩c".toWellFormed() === "a💩c", + "a\\uD83D\\uDCA9c".toWellFormed() === "a\\uD83D\\uDCA9c", + ("a" + leadingPoo + trailingPoo + "d").toWellFormed() === "a" + wholePoo + "d", + wholePoo.slice(0, 1).toWellFormed() === replacementChar, + wholePoo.slice(1).toWellFormed() === replacementChar, + "abc".toWellFormed() === "abc", + "a\\u25A8c".toWellFormed() === "a\\u25A8c", + ] + `), + ).toEqual(["function", true, true, true, true, true, true, true, true, true, true]) + }) +}) + +describe("Date string formatting Test262 parity", () => { + test("test/built-ins/Date/prototype/toDateString/format.js", async () => { + expect( + await value(` + const dateRegExp = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{2} [0-9]{4}$/ + return [dateRegExp.test(new Date(0).toDateString()), dateRegExp.test(new Date("0020-01-01T00:00:00Z").toDateString())] + `), + ).toEqual([true, true]) + }) + + test("test/built-ins/Date/prototype/toDateString/invalid-date.js", async () => { + expect(await value(`return new Date(NaN).toDateString()`)).toBe("Invalid Date") + }) + + test("test/built-ins/Date/prototype/toDateString/negative-year.js", async () => { + expect( + await value(` + return ["-000001", "-000012", "-000123", "-001234", "-012345", "-123456"].map( + (year) => new Date(year + "-07-01T00:00Z").toDateString().split(" ")[3], + ) + `), + ).toEqual(["-0001", "-0012", "-0123", "-1234", "-12345", "-123456"]) + }) + + test("test/built-ins/Date/prototype/toTimeString/format.js", async () => { + expect( + await value(` + const timeRegExp = /^[0-9]{2}:[0-9]{2}:[0-9]{2} GMT[+-][0-9]{4}( \\(.+\\))?$/ + return timeRegExp.test(new Date(0).toTimeString()) + `), + ).toBe(true) + }) + + test("test/built-ins/Date/prototype/toTimeString/invalid-date.js", async () => { + expect(await value(`return new Date(NaN).toTimeString()`)).toBe("Invalid Date") + }) +})