diff --git a/CHANGELOG.md b/CHANGELOG.md index ac18d58c61..9aa4a969ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## v0.5.1163 — fix: chalk boolean style modifiers — `` no longer renders `[object Object]` (#5039) + +Fixes #5039 — ink's `` (and `bold`/`italic`/`underline`/…) +rendered `[object Object]` instead of the styled text. chalk 5's style +modifiers are **getter accessors on a function prototype chain** +(`Object.defineProperties(createChalk.prototype, styles)` reached through +`Object.setPrototypeOf(chalk, createChalk.prototype)` where `chalk` is itself +a function), and four independent Perry gaps conspired so `chalk.dim` never +resolved: + +1. **runtime** (`closure/dynamic_props.rs`): `closure_get_dynamic_prop`'s + static-prototype walk only consulted data properties. Both arms (closure + proto and plain-object proto) now resolve ACCESSOR descriptors, invoking + the getter with the ORIGINAL receiver (`clone_closure_rebind_this`) so + chalk's `Object.defineProperty(this, styleName, {value: builder})` + self-cache lands on the instance instead of throwing "Cannot redefine + property: dim" against the non-configurable accessor on the shared proto. +2. **codegen** (`lower_call/property_get.rs`): Annex B HTML-wrapper names + (`bold`, `link`, `anchor`, `big`, …) on *any*-typed receivers were + force-routed to `String.prototype`, so `chalk.bold(s)` coerced the chalk + closure to its source text and returned `(...strings) => strings.join(' ')`. + Dropped from the force list; an Any receiver that really is a string still + resolves through the `jsval.is_string()` arm of `js_native_call_method`. +3. **resolver** (`commands/compile/resolve.rs`): Node subpath imports + (`#`-prefixed specifiers via the package's own `package.json` `"imports"` + map) were unsupported, so chalk's `import ansiStyles from '#ansi-styles'` + (vendored dep) silently resolved to nothing and EVERY style table came up + empty — `color="cyan"` only *looked* fine because ink fell back to the + unstyled string. Conditional entries prefer `node` over `default` (chalk's + `#supports-color` ships a browser build as `default`). +4. **HIR** (`lower_patterns.rs`): `unescape_template` didn't decode + `\xHH` / `\uHHHH` / `\u{…}` (plus `\b\f\v\0` and line continuations), so + ansi-styles' `` `\u001B[${code}m` `` template literals produced the + 6-char literal text instead of ESC. Surrogate pairs combine; a lone + surrogate becomes U+FFFD (WTF-8 remains a categorical gap). + +Validation: new `test-files/test_gap_chalk_proto_styles_5039.ts` is +byte-identical vs `node --experimental-strip-types`; real chalk 5 compiled +via `perry.compilePackages` produces Node-identical ANSI output for +`dim`/`bold`/`cyan` including nested `chalk.dim.bold`; the issue's ink repro +(on top of #5038's yoga-taffy branch) renders all four lines correctly with +real escape codes. 3 new resolver unit tests cover the imports map (exact, +conditional node/default, unmapped). Touched-crate test suites green. + +Deferred (pre-existing, observed while validating): `Object.entries` on +ansi-styles' assembled object returns 55 entries vs Node's 45 +(non-enumerable `defineProperty` slots leak into enumeration); +`JSON.stringify` emits `\u000c`/`\u0008` instead of the `\f`/`\b` short +forms. + ## v0.5.1162 — feat: native yoga-layout via taffy + JSON module imports + Context.Provider fix (ink #348 end-to-end) Makes `ink` (React-based TUI) compile **and render** fully natively — no WASM diff --git a/CLAUDE.md b/CLAUDE.md index 247b3d0f0c..6de25dbbbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1162 +**Current Version:** 0.5.1163 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 07b69c6d66..6e5d17ddff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "base64", @@ -5338,14 +5338,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "cc", "libc", @@ -5353,7 +5353,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "log", @@ -5368,7 +5368,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-hir", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-hir", @@ -5385,7 +5385,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-dispatch", @@ -5395,7 +5395,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-hir", @@ -5404,7 +5404,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "base64", @@ -5417,7 +5417,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-hir", @@ -5425,7 +5425,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "async-trait", @@ -5454,14 +5454,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "serde", "serde_json", @@ -5469,7 +5469,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1162" +version = "0.5.1163" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5480,7 +5480,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "clap", @@ -5495,14 +5495,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "argon2", "perry-ffi", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "reqwest", @@ -5519,7 +5519,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "bcrypt", "perry-ffi", @@ -5527,7 +5527,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "rusqlite", @@ -5535,7 +5535,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "scraper", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "perry-runtime", @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "chrono", "cron 0.16.0", @@ -5561,7 +5561,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "chrono", "perry-ffi", @@ -5569,7 +5569,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "rust_decimal", @@ -5577,7 +5577,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "serde_json", @@ -5585,7 +5585,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5593,7 +5593,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "perry-runtime", @@ -5601,14 +5601,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "bytes", "http-body-util", @@ -5625,7 +5625,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "lazy_static", "perry-ffi", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5650,7 +5650,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "bytes", "h2", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "lazy_static", "perry-ffi", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "jsonwebtoken", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "lru", "perry-ffi", @@ -5702,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "chrono", "perry-ffi", @@ -5710,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "bson", "futures-util", @@ -5722,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "chrono", "perry-ffi", @@ -5732,7 +5732,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "nanoid", "perry-ffi", @@ -5741,7 +5741,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "perry-runtime", @@ -5753,7 +5753,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "lettre", "perry-ffi", @@ -5763,7 +5763,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "printpdf", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "sqlx", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "governor", "perry-ffi", @@ -5788,7 +5788,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "image", @@ -5797,14 +5797,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "lazy_static", "perry-ffi", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "uuid", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ffi", "regex", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "futures-util", "lazy_static", @@ -5843,7 +5843,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "brotli", "flate2", @@ -5852,7 +5852,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "dashmap", "once_cell", @@ -5861,7 +5861,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-api-manifest", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-diagnostics", @@ -5890,7 +5890,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "base64", @@ -5922,7 +5922,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6014,7 +6014,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "perry-hir", @@ -6024,7 +6024,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6032,14 +6032,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "itoa", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "rand 0.8.6", "serde", @@ -6066,7 +6066,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "cairo-rs", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "block2", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "block2", @@ -6120,7 +6120,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1162" +version = "0.5.1163" [[package]] name = "perry-ui-test" @@ -6128,11 +6128,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1162" +version = "0.5.1163" [[package]] name = "perry-ui-tvos" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "block2", @@ -6148,7 +6148,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "block2", @@ -6164,7 +6164,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "block2", "libc", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "libc", @@ -6194,14 +6194,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "base64", "ed25519-dalek", @@ -6215,7 +6215,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1162" +version = "0.5.1163" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 1aa69e4ebe..a65802de43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -201,7 +201,7 @@ codegen-units = 16 opt-level = "s" # Optimize for size in stdlib [workspace.package] -version = "0.5.1162" +version = "0.5.1163" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index 907241875f..fb8e251f7e 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -329,10 +329,14 @@ pub fn try_lower_property_get_method_call( "split" | "charCodeAt" | "charAt" | "trim" | "trimStart" | "trimEnd" | "substring" | "substr" | "toLowerCase" | "toUpperCase" | "toLocaleLowerCase" | "toLocaleUpperCase" | "replaceAll" | "padStart" | "padEnd" | "repeat" - | "normalize" | "codePointAt" | "localeCompare" - // Annex B §B.2.2 HTML wrappers — string-only, no array/map/set form. - | "anchor" | "big" | "blink" | "bold" | "fixed" | "fontcolor" | "fontsize" - | "italics" | "link" | "small" | "strike" | "sub" | "sup" => true, + | "normalize" | "codePointAt" | "localeCompare" => true, + // Annex B §B.2.2 HTML wrappers (`bold`, `link`, `anchor`, …) are + // string-only in the spec but collide with common user method + // names — chalk's `chalk.bold(s)` is a styled-string builder + // (#5039). Forcing the string path here coerced the chalk closure + // to its source text and wrapped it in ``. An Any-typed + // receiver that really is a string still gets them via the + // `jsval.is_string()` arm of `js_native_call_method`. // Issue #638: `replace` is also string-exclusive, but routing // it here unconditionally caused regressions in async dispatch // pathways. Only fire when args[1] is statically detectable as diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index 595d07e5e0..29dfe0dab9 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -21,9 +21,38 @@ pub(crate) fn unescape_template(s: &str) -> String { Some('n') => result.push('\n'), Some('t') => result.push('\t'), Some('r') => result.push('\r'), + Some('b') => result.push('\u{0008}'), + Some('f') => result.push('\u{000C}'), + Some('v') => result.push('\u{000B}'), Some('\\') => result.push('\\'), Some('$') => result.push('$'), Some('`') => result.push('`'), + Some('\'') => result.push('\''), + Some('"') => result.push('"'), + // `\0` (not followed by another digit) is NUL. + Some('0') if !chars.peek().is_some_and(|d| d.is_ascii_digit()) => result.push('\0'), + // Line continuation: backslash-newline contributes nothing. + Some('\n') => {} + Some('\r') => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + } + // `\xHH` / `\uHHHH` / `\u{H…}` — #5039: ansi-styles builds its + // escape codes as `` `\u001B[${code}m` `` template literals; + // falling through to the literal-backslash arm turned every + // chalk style into the 6-char literal source text instead of ESC. + Some(esc @ ('x' | 'u')) => { + if let Some(decoded) = unescape_hex_escape(esc, &mut chars) { + result.push_str(&decoded); + } else { + // Invalid escape (only reachable in tagged templates, + // where cooked semantics are undefined) — keep the + // original text. + result.push('\\'); + result.push(esc); + } + } Some(other) => { result.push('\\'); result.push(other); @@ -38,6 +67,75 @@ pub(crate) fn unescape_template(s: &str) -> String { result } +/// Decode the body of a `\xHH`, `\uHHHH`, or `\u{H…}` escape, with `esc` +/// being the introducer character just consumed (`x` or `u`). A `\uD800–DBFF` +/// high surrogate followed immediately by an escaped low surrogate decodes as +/// the combined supplementary code point; a lone surrogate becomes U+FFFD +/// (Perry strings are UTF-8 — see the WTF-8 categorical gap in CLAUDE.md). +/// Returns `None` (consuming nothing further) on malformed hex so the caller +/// can preserve the source text. +fn unescape_hex_escape( + esc: char, + chars: &mut std::iter::Peekable>, +) -> Option { + fn hex_fixed(chars: &mut std::iter::Peekable>, n: usize) -> Option { + let mut value = 0u32; + for _ in 0..n { + let d = chars.peek()?.to_digit(16)?; + chars.next(); + value = value * 16 + d; + } + Some(value) + } + fn hex_braced(chars: &mut std::iter::Peekable>) -> Option { + chars.next(); // consume '{' + let mut value = 0u32; + let mut any = false; + loop { + match chars.peek() { + Some('}') => { + chars.next(); + return any.then_some(value); + } + Some(c) => { + let d = c.to_digit(16)?; + chars.next(); + any = true; + value = value.checked_mul(16)?.checked_add(d)?; + if value > 0x10FFFF { + return None; + } + } + None => return None, + } + } + } + + let code = if esc == 'x' { + hex_fixed(chars, 2)? + } else if chars.peek() == Some(&'{') { + hex_braced(chars)? + } else { + hex_fixed(chars, 4)? + }; + + // High surrogate: try to pair with an immediately following `\uDC00–DFFF`. + if (0xD800..=0xDBFF).contains(&code) { + let mut lookahead = chars.clone(); + if lookahead.next() == Some('\\') && lookahead.next() == Some('u') { + if let Some(low) = hex_fixed(&mut lookahead, 4) { + if (0xDC00..=0xDFFF).contains(&low) { + *chars = lookahead; + let combined = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00); + return char::from_u32(combined).map(String::from); + } + } + } + } + + Some(char::from_u32(code).unwrap_or('\u{FFFD}').to_string()) +} + pub(crate) fn lower_lit(lit: &ast::Lit) -> Result { match lit { ast::Lit::Num(n) => { diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 3f7a867166..78dca4d22e 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -399,6 +399,29 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { // regular object, read the named field via the field getter; for a // closure, recurse via its own props. Distinguish by CLOSURE_MAGIC. if is_closure_ptr(proto_ptr) { + // #5039: the proto may carry accessor properties — chalk's style + // proto is `Object.defineProperties(() => {}, styles)` where every + // style is `{ get() {...} }`. Invoke the getter with the ORIGINAL + // receiver (`ptr`, not the proto) so chalk's + // `Object.defineProperty(this, styleName, {value: builder})` + // caches the builder on the chalk instance, and nested builders + // chain their stylers off the right `this`. + if let Some(acc) = crate::object::get_accessor_descriptor(proto_ptr, prop) { + if acc.get == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let receiver = crate::value::js_nanbox_pointer(ptr as i64); + let getter_bits = clone_closure_rebind_this(acc.get, receiver); + let getter = (getter_bits & crate::value::POINTER_MASK) + as *const crate::closure::ClosureHeader; + if getter.is_null() { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let prev = crate::object::js_implicit_this_set(receiver); + let result = crate::closure::js_closure_call0(getter); + crate::object::js_implicit_this_set(prev); + return result; + } if let Ok(props) = get_closure_props().lock() { if let Some(p) = props.get(&proto_ptr).and_then(|m| m.get(prop)) { return *p; @@ -408,6 +431,28 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { depth += 1; continue; } + // #5039: an accessor on the proto object must run with the ORIGINAL + // closure as receiver, not the proto. chalk's style getters live on + // `createChalk.prototype` and cache the built style via + // `Object.defineProperty(this, styleName, {value: builder})` — with + // `this` = proto that's a TypeError (redefining the non-configurable + // accessor) instead of an own-property cache on the chalk instance. + if let Some(acc) = crate::object::get_accessor_descriptor(proto_ptr, prop) { + if acc.get == 0 { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let receiver = crate::value::js_nanbox_pointer(ptr as i64); + let getter_bits = clone_closure_rebind_this(acc.get, receiver); + let getter = + (getter_bits & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; + if getter.is_null() { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + let prev = crate::object::js_implicit_this_set(receiver); + let result = crate::closure::js_closure_call0(getter); + crate::object::js_implicit_this_set(prev); + return result; + } unsafe { let key_hdr = crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32); let v = crate::object::js_object_get_field_by_name( diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 0a8e8677f3..2cd742f014 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -623,6 +623,39 @@ pub(super) fn resolve_exports(exports: &serde_json::Value, subpath: &str) -> Opt ) } +/// Node subpath imports (#5039): resolve a `#`-prefixed specifier through the +/// importing package's own `package.json` `"imports"` map +/// (https://nodejs.org/api/packages.html#imports). chalk 5 loads its vendored +/// dependencies this way (`import ansiStyles from '#ansi-styles'` → +/// `./source/vendor/ansi-styles/index.js`), so without this every compiled +/// chalk style table came up empty. The map shares the `exports` value shape +/// (string / conditional object / `*` patterns), so the same resolver is +/// reused — with `node` ranked above `default` so conditional pairs like +/// chalk's `#supports-color` `{ node, default: browser }` pick the node build +/// for native compilation. Per Node's package-scope rule, only the NEAREST +/// `package.json` up from the importer is consulted. +fn resolve_subpath_import(import_source: &str, importer_path: &Path) -> Option { + let mut dir = importer_path.parent(); + while let Some(d) = dir { + let pkg_json = d.join("package.json"); + if pkg_json.is_file() { + let content = std::fs::read_to_string(&pkg_json).ok()?; + let json: serde_json::Value = serde_json::from_str(&content).ok()?; + let target = resolve_exports_with_conditions( + json.get("imports")?, + import_source, + &["perry", "node", "import", "module", "default", "require"], + )?; + let base = d.join(target.trim_start_matches("./")); + return resolve_with_extensions(&base) + .and_then(|p| p.canonicalize().ok()) + .or_else(|| base.canonicalize().ok()); + } + dir = d.parent(); + } + None +} + fn canonical_existing_declaration(path: PathBuf) -> Option { if path.exists() && is_declaration_file(&path) { Some(path.canonicalize().unwrap_or(path)) @@ -804,9 +837,26 @@ pub(super) fn resolve_import( return None; // Native modules are handled by stdlib, not file imports } + // Node subpath imports (`#…`, #5039) resolve through the importing + // package's own `"imports"` map and then classify exactly like a relative + // import to the mapped file. + let subpath_import_target = if import_source.starts_with('#') { + match resolve_subpath_import(import_source, importer_path) { + Some(canonical) => Some(canonical), + None => return None, + } + } else { + None + }; + // Handle relative imports (./ or ../) - if import_source.starts_with("./") || import_source.starts_with("../") { - if let Some(canonical) = resolve_relative_import_path(import_source, importer_path) { + if import_source.starts_with("./") + || import_source.starts_with("../") + || subpath_import_target.is_some() + { + if let Some(canonical) = subpath_import_target + .or_else(|| resolve_relative_import_path(import_source, importer_path)) + { // Refs #486: a relative `import './foo.js'` from inside a compile // package must classify as NativeCompiled even when the resolved // file lives outside the literal `node_modules//` substring diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index e54e7522aa..49dbc2e32d 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1576,3 +1576,122 @@ mod declaration_sidecar_tests { assert_eq!(found.len(), 5, "exactly the five real packages"); } } + +/// Issue #5039 — Node subpath imports (`#…` specifiers resolved through the +/// importing package's own `package.json` `"imports"` map). chalk 5 loads +/// its vendored deps this way (`import ansiStyles from '#ansi-styles'`); +/// before the fix the specifier fell through bare-package resolution, the +/// import silently came up empty, and every chalk style table was blank +/// (ink's `` rendered `[object Object]`). +mod subpath_imports_tests { + use super::super::{resolve_import, ModuleKind}; + use std::collections::{HashMap, HashSet}; + use std::path::PathBuf; + + fn write_chalk_like_package(root: &std::path::Path) -> PathBuf { + let pkg = root.join("node_modules/chalky"); + std::fs::create_dir_all(pkg.join("source/vendor/ansi-styles")).unwrap(); + std::fs::create_dir_all(pkg.join("source/vendor/supports-color")).unwrap(); + std::fs::write( + pkg.join("package.json"), + r##"{ + "name": "chalky", + "type": "module", + "exports": "./source/index.js", + "imports": { + "#ansi-styles": "./source/vendor/ansi-styles/index.js", + "#supports-color": { + "node": "./source/vendor/supports-color/index.js", + "default": "./source/vendor/supports-color/browser.js" + } + } + }"##, + ) + .unwrap(); + std::fs::write(pkg.join("source/index.js"), "export default 1;\n").unwrap(); + std::fs::write( + pkg.join("source/vendor/ansi-styles/index.js"), + "export default {};\n", + ) + .unwrap(); + std::fs::write( + pkg.join("source/vendor/supports-color/index.js"), + "export default { stdout: false };\n", + ) + .unwrap(); + std::fs::write( + pkg.join("source/vendor/supports-color/browser.js"), + "export default { stdout: false };\n", + ) + .unwrap(); + pkg + } + + #[test] + fn hash_specifier_resolves_through_imports_map() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let pkg = write_chalk_like_package(root); + let importer = pkg.join("source/index.js"); + + let compile_packages: HashSet = ["chalky".to_string()].into_iter().collect(); + let compile_package_dirs: HashMap = HashMap::new(); + + let (resolved, kind) = resolve_import( + "#ansi-styles", + &importer, + root, + &compile_packages, + &compile_package_dirs, + ) + .expect("#ansi-styles must resolve via the imports map"); + assert_eq!( + resolved, + pkg.join("source/vendor/ansi-styles/index.js") + .canonicalize() + .unwrap() + ); + // The mapped file is inside a compile package → compiled natively. + assert_eq!(kind, ModuleKind::NativeCompiled); + } + + #[test] + fn conditional_imports_target_prefers_node_over_default() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let pkg = write_chalk_like_package(root); + let importer = pkg.join("source/index.js"); + + let compile_packages: HashSet = ["chalky".to_string()].into_iter().collect(); + let compile_package_dirs: HashMap = HashMap::new(); + + let (resolved, _) = resolve_import( + "#supports-color", + &importer, + root, + &compile_packages, + &compile_package_dirs, + ) + .expect("#supports-color must resolve via the imports map"); + assert_eq!( + resolved, + pkg.join("source/vendor/supports-color/index.js") + .canonicalize() + .unwrap(), + "conditional imports entry must pick `node`, not the browser `default`" + ); + } + + #[test] + fn unmapped_hash_specifier_stays_unresolved() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let pkg = write_chalk_like_package(root); + let importer = pkg.join("source/index.js"); + + assert!( + resolve_import("#nope", &importer, root, &HashSet::new(), &HashMap::new(),).is_none(), + "a `#` specifier missing from the imports map must not resolve" + ); + } +} diff --git a/test-files/test_gap_chalk_proto_styles_5039.ts b/test-files/test_gap_chalk_proto_styles_5039.ts new file mode 100644 index 0000000000..2177eae493 --- /dev/null +++ b/test-files/test_gap_chalk_proto_styles_5039.ts @@ -0,0 +1,137 @@ +// Issue #5039: ink `` rendered `[object Object]` because +// chalk's boolean style modifiers never resolved. Three single-file roots +// (the fourth — `#`-subpath imports — is covered by resolver unit tests): +// +// 1. `closure_get_dynamic_prop`'s static-prototype walk ignored ACCESSOR +// properties: chalk's styles are `{ get() {…} }` descriptors on +// `createChalk.prototype` (and on a bare-function proto for builders), +// and the getters must run with the ORIGINAL receiver so chalk's +// `Object.defineProperty(this, styleName, {value: builder})` caches on +// the instance instead of throwing "Cannot redefine property". +// 2. Codegen force-routed Annex B HTML-wrapper names (`bold`, `link`, …) on +// any-typed receivers to String.prototype, so `chalk.bold(s)` coerced the +// chalk closure to its source text and wrapped it in ``. +// 3. `unescape_template` didn't decode `\xHH` / `\uHHHH` / `\u{…}`, so +// ansi-styles' `` `\u001B[${code}m` `` produced 6 literal chars, not ESC. + +// --- Part 1: getter on a function used as a set prototype (builder proto) --- +const fnProto: any = Object.defineProperties(() => {}, { + dim: { get() { return (x: string) => 'DIM:' + x; } }, +}); +const fnTarget: any = (...s: any[]) => s.join(' '); +Object.setPrototypeOf(fnTarget, fnProto); +console.log('fn-proto read:', typeof fnTarget.dim); +console.log('fn-proto call:', fnTarget.dim('a')); + +// --- Part 2: chalk's exact shape — getter on createChalk.prototype with a +// defineProperty self-cache, reached through Object.setPrototypeOf(fn, …) --- +const GENERATOR = Symbol('GENERATOR'); +const STYLER = Symbol('STYLER'); +const IS_EMPTY = Symbol('IS_EMPTY'); + +const ansiStyles: any = { + dim: { open: '\u001B[2m', close: '\u001B[22m' }, + bold: { open: '\u001B[1m', close: '\u001B[22m' }, + cyan: { open: '\u001B[36m', close: '\u001B[39m' }, +}; + +const styles: any = Object.create(null); + +const applyOptions = (object: any, options: any = {}) => { + object.level = options.level === undefined ? 1 : options.level; +}; + +const chalkFactory = (options?: any) => { + const chalk = (...strings: any[]) => strings.join(' '); + applyOptions(chalk, options); + Object.setPrototypeOf(chalk, createChalk.prototype); + return chalk; +}; + +function createChalk(options?: any): any { + return chalkFactory(options); +} + +Object.setPrototypeOf(createChalk.prototype, Function.prototype); + +for (const [styleName, style] of Object.entries(ansiStyles)) { + styles[styleName] = { + get() { + const builder = createBuilder(this, createStyler((style as any).open, (style as any).close, (this as any)[STYLER]), (this as any)[IS_EMPTY]); + Object.defineProperty(this, styleName, { value: builder }); + return builder; + }, + }; +} + +const proto: any = Object.defineProperties(() => {}, { + ...styles, + level: { + enumerable: true, + get() { return (this as any)[GENERATOR].level; }, + set(level: any) { (this as any)[GENERATOR].level = level; }, + }, +}); + +const createStyler = (open: string, close: string, parent?: any) => { + let openAll; + let closeAll; + if (parent === undefined) { + openAll = open; + closeAll = close; + } else { + openAll = parent.openAll + open; + closeAll = close + parent.closeAll; + } + return { open, close, openAll, closeAll, parent }; +}; + +const createBuilder = (self: any, _styler: any, _isEmpty: boolean) => { + const builder = (...arguments_: any[]) => applyStyle(builder, (arguments_.length === 1) ? ('' + arguments_[0]) : arguments_.join(' ')); + Object.setPrototypeOf(builder, proto); + builder[GENERATOR] = self; + builder[STYLER] = _styler; + builder[IS_EMPTY] = _isEmpty; + return builder; +}; + +const applyStyle = (self: any, string: string) => { + if (self.level <= 0 || !string) { + return self[IS_EMPTY] ? '' : string; + } + let styler = self[STYLER]; + if (styler === undefined) { + return string; + } + const { openAll, closeAll } = styler; + return openAll + string + closeAll; +}; + +Object.defineProperties(createChalk.prototype, styles); + +const chalk = createChalk(); +console.log('dim :', JSON.stringify(chalk.dim('hello'))); +console.log('bold:', JSON.stringify(chalk.bold('hello'))); +console.log('cyan:', JSON.stringify(chalk.cyan('hello'))); +console.log('nest:', JSON.stringify(chalk.dim.bold('hello'))); +// Second read hits the defineProperty self-cache instead of the getter. +console.log('cached:', JSON.stringify(chalk.dim('again'))); + +// --- Part 3: Annex B HTML wrappers still work on real strings, including +// any-typed ones, while object methods with colliding names win dispatch --- +console.log('str bold typed:', 'x'.bold()); +const anyStr: any = 'y'; +console.log('str bold any :', anyStr.bold()); + +// --- Part 4: template-literal escapes --- +const esc = (code: number) => `\u001B[${code}m`; +const e = esc(2); +console.log('tpl u-escape:', e.length, e.charCodeAt(0), e.charCodeAt(1)); +const hexEsc = `\x1B[0m`; +console.log('tpl x-escape:', hexEsc.length, hexEsc.charCodeAt(0)); +const braced = `\u{1F600}ok`; +console.log('tpl braced :', braced.length, braced.codePointAt(0)); +// Compared as char codes: Perry's JSON.stringify writes \u000c/\u0008 where +// Node uses the \f/\b short forms (pre-existing formatting gap, not #5039). +const misc = `a\0b\vc\fd\be`; +console.log('tpl misc :', misc.length, Array.from(misc).map(c => c.charCodeAt(0)).join(','));