diff --git a/Cargo.lock b/Cargo.lock index 3f959e912a..076edcba17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2117,6 +2117,7 @@ version = "0.75.0" dependencies = [ "cow-utils", "insta", + "memchr", "oxc-graphql-parser", "oxc_allocator", "oxc_diagnostics", diff --git a/crates/oxc_formatter_graphql/AGENTS.md b/crates/oxc_formatter_graphql/AGENTS.md index ac500e70a9..d4bbf936f2 100644 --- a/crates/oxc_formatter_graphql/AGENTS.md +++ b/crates/oxc_formatter_graphql/AGENTS.md @@ -54,7 +54,8 @@ This crate instead collects comment spans into a positional cursor, drained in s ### Strings Prettier prints `StringValue` from `graphql-js`'s _cooked_ value and re-encodes it. -`oxc-graphql-parser`'s `StringValue.value` is cooked but not to spec (no block-string dedent / blank-line trimming, no surrogate pairing), so `src/print/string.rs` cooks from `raw` itself: +`oxc-graphql-parser`'s `StringValue.value` is cooked but not to spec (no block-string dedent / blank-line trimming, no surrogate pairing), so `src/print/string.rs` cooks it itself, +a regular string from `raw` and a block string from `value` (only its line endings normalized): - the GraphQL spec `BlockStringValue` algorithm (dedent + blank-line trimming) - escape decoding for regular strings (incl. surrogate pairs) diff --git a/crates/oxc_formatter_graphql/Cargo.toml b/crates/oxc_formatter_graphql/Cargo.toml index 968ee3cd6f..660195ad58 100644 --- a/crates/oxc_formatter_graphql/Cargo.toml +++ b/crates/oxc_formatter_graphql/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [dependencies] oxc-graphql-parser = { workspace = true } cow-utils = { workspace = true } +memchr = { workspace = true } oxc_allocator = { workspace = true } oxc_diagnostics = { workspace = true } oxc_formatter_core = { workspace = true } diff --git a/crates/oxc_formatter_graphql/src/print/string.rs b/crates/oxc_formatter_graphql/src/print/string.rs index 84e5106b5e..6b39a132a2 100644 --- a/crates/oxc_formatter_graphql/src/print/string.rs +++ b/crates/oxc_formatter_graphql/src/print/string.rs @@ -5,12 +5,16 @@ //! `oxc-graphql-parser`'s `StringValue.value` is cooked too, but NOT to spec: //! no block-string dedent / blank-line trimming, no surrogate pairing, and //! invalid escapes are silently dropped. -//! So this module cooks from `raw` itself (GraphQL spec string/block-string value algorithms) -//! and then applies Prettier's re-encoding (`"`/`\` escaped, newline as `\n`, `"""` as `\"""`). +//! So this module cooks them itself (GraphQL spec string/block-string value algorithms): +//! a regular string from `raw`, a block string from `value`, which only has its line endings normalized. +//! Then it applies Prettier's re-encoding (`"`/`\` escaped, newline as `\n`, `"""` as `\"""`). + +use std::borrow::Cow; use cow_utils::CowUtils; use oxc_formatter_core::{ + arena_cow_str, builders::{exact_line_breaks, hard_line_break, text}, write, }; @@ -20,8 +24,8 @@ use super::GraphqlFormatter; pub(super) fn write_string_value<'a>(sv: &StringValue<'a>, f: &mut GraphqlFormatter<'_, 'a>) { let raw = sv.raw; - if let Some(body) = raw.strip_prefix("\"\"\"").and_then(|r| r.strip_suffix("\"\"\"")) { - write_block_string(body, f); + if sv.block { + write_block_string(sv.value, f); } else if let Some(body) = raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')) { write_regular_string(body, f); } else { @@ -32,21 +36,28 @@ pub(super) fn write_string_value<'a>(sv: &StringValue<'a>, f: &mut GraphqlFormat /// Block string: cook per the GraphQL `BlockStringValue` algorithm, re-escape `"""`, /// then print one line per hard line break (Prettier's shape). -fn write_block_string(body: &str, f: &mut GraphqlFormatter<'_, '_>) { - // The only escape sequence in block strings. - let unescaped = body.cow_replace("\\\"\"\"", "\"\"\""); - let cooked_lines = cook_block_string_lines(&unescaped); +fn write_block_string<'a>(body: &'a str, f: &mut GraphqlFormatter<'_, 'a>) { + // `\"""` is the only escape in block strings. + // Precheck its `\`: `cow_replace` builds a substring searcher per call + let unescaped = if body.contains('\\') { + body.cow_replace("\\\"\"\"", "\"\"\"") + } else { + Cow::Borrowed(body) + }; + // Without one, no line holds a `"""` to re-escape: the lines print borrowed from the source. + // Re-escaping touches no whitespace, so it is done when printing + let escapes = matches!(unescaped, Cow::Owned(_)); + let mut lines = cook_block_string_lines(arena_cow_str(&unescaped, f)); - // Prettier: re-escape, then `lines.length === 1` → trim, all-blank → drop everything. + // Prettier: `lines.length === 1` → trim, all-blank → drop everything. // Trailing spaces/tabs are trimmed per line. - let mut lines: Vec = cooked_lines - .iter() - .map(|l| l.trim_end_matches([' ', '\t']).cow_replace("\"\"\"", "\\\"\"\"").into_owned()) - .collect(); - if lines.len() == 1 { - lines[0] = lines[0].trim().to_string(); + for line in &mut lines { + *line = line.trim_end_matches([' ', '\t']); } - if lines.iter().all(std::string::String::is_empty) { + if let [line] = lines.as_mut_slice() { + *line = line.trim(); + } + if lines.iter().all(|line| line.is_empty()) { lines.clear(); } @@ -55,14 +66,16 @@ fn write_block_string(body: &str, f: &mut GraphqlFormatter<'_, '_>) { // blank lines inside a block string are part of its VALUE, // so a run of `k` blank lines is emitted as `exact_line_breaks(k + 1)`. let mut pending_blanks = 0usize; - for line in &lines { + for line in lines { if line.is_empty() { pending_blanks += 1; continue; } write_block_string_break(pending_blanks, f); pending_blanks = 0; - write!(f, text(f.allocator().alloc_str(line))); + let line = + if escapes { arena_cow_str(&line.cow_replace("\"\"\"", "\\\"\"\""), f) } else { line }; + write!(f, text(line)); } write_block_string_break(pending_blanks, f); write!(f, "\"\"\""); @@ -79,7 +92,7 @@ fn write_block_string_break(blank_lines: usize, f: &mut GraphqlFormatter<'_, '_> /// GraphQL spec `BlockStringValue`: split lines, strip the common indentation of /// non-first lines, then drop leading/trailing blank lines. fn cook_block_string_lines(value: &str) -> Vec<&str> { - let mut lines: Vec<&str> = split_graphql_lines(value); + let mut lines: Vec<&str> = split_lines(value); // Common indent over non-blank lines after the first. let mut common_indent: Option = None; @@ -107,26 +120,13 @@ fn cook_block_string_lines(value: &str) -> Vec<&str> { lines } -/// Splits on GraphQL line terminators: `\r\n`, lone `\r`, `\n`. -fn split_graphql_lines(value: &str) -> Vec<&str> { - let bytes = value.as_bytes(); +/// `value.split('\n')`, by the SIMD `memchr` rather than core's +fn split_lines(value: &str) -> Vec<&str> { let mut lines = Vec::new(); let mut start = 0; - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\n' => { - lines.push(&value[start..i]); - i += 1; - start = i; - } - b'\r' => { - lines.push(&value[start..i]); - i += if bytes.get(i + 1) == Some(&b'\n') { 2 } else { 1 }; - start = i; - } - _ => i += 1, - } + for i in memchr::memchr_iter(b'\n', value.as_bytes()) { + lines.push(&value[start..i]); + start = i + 1; } lines.push(&value[start..]); lines