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
2 changes: 1 addition & 1 deletion apps/oxfmt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ rayon = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_json = { workspace = true, features = ["unbounded_depth"] }
simdutf8 = { workspace = true }
sort-package-json = { workspace = true }
oxc-toml = { workspace = true }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
markdown`
Hello \`こんにちは\` world
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
md`
- item1
\`\`\`js
console.log("hello");
\`\`\`
- item2
`

function f() {
return md`
- outer item
\`\`\`js
const x = 1;
\`\`\`
- another
- nested list
\`\`\`bash
npm install
\`\`\`
`;
}
13 changes: 13 additions & 0 deletions apps/oxfmt/conformance/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ const categories: Category[] = [
optionSets: [{ printWidth: 80 }, { printWidth: 100, htmlWhitespaceSensitivity: "ignore" }],
notes: {},
},
{
name: "md-in-js",
sources: [
{
dir: join(FIXTURES_DIR, "prettier", "js/multiparser-markdown"),
ext: ".js",
excludes: ["format.test.js"],
},
{ dir: join(FIXTURES_DIR, "edge-cases", "md-in-js") },
],
optionSets: [{ printWidth: 80 }, { printWidth: 100, proseWrap: "always" }],
notes: {},
},
{
name: "xxx-in-js-comment",
sources: [
Expand Down
14 changes: 14 additions & 0 deletions apps/oxfmt/conformance/snapshots/conformance.snap.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@
{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
```

## md-in-js

### Option 1: 8/8 (100.00%)

```json
{"printWidth":80}
```

### Option 2: 8/8 (100.00%)

```json
{"printWidth":100,"proseWrap":"always"}
```

## xxx-in-js-comment

### Option 1: 5/5 (100.00%)
Expand Down
8 changes: 8 additions & 0 deletions apps/oxfmt/src-js/libs/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ async function loadPrettier(): Promise<typeof import("prettier")> {
// - or flaky traversal of the `Doc` output
// to extract the same information, since this hooks into the AST.
formatOptionsHiddenDefaults.__onHtmlRoot = null;
// For md-in-js: Use `~` instead of `` ` `` for code fences
formatOptionsHiddenDefaults.__inJsTemplate = null;

return prettierCache;
}
Expand Down Expand Up @@ -169,6 +171,12 @@ export async function formatEmbeddedDoc({
(metadata.htmlHasMultipleRootElements = (root.children?.length ?? 0) > 1);
}

// md-in-js specific options: see the comment in `loadPrettier()` for rationale
if (options.parser === "markdown") {
// https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/markdown.js#L21
options.__inJsTemplate = true;
}

// @ts-expect-error: Use internal API, but it's necessary and only way to get `Doc`
const doc = await prettier.__debug.printToDoc(text, options);

Expand Down
24 changes: 18 additions & 6 deletions apps/oxfmt/src/core/external_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ use napi::{
bindgen_prelude::{FnArgs, Promise, block_on},
threadsafe_function::ThreadsafeFunction,
};
use serde::Deserialize;
use serde_json::Value;
use tracing::debug_span;
use tracing::{debug, debug_span};

use oxc_formatter::{
EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, ExternalCallbacks, FormatOptions,
Expand Down Expand Up @@ -267,10 +268,8 @@ impl ExternalFormatter {
code.truncate(trimmed_len);
code
})
.map_err(|err| {
format!(
"Failed to format embedded code for parser '{parser_name}': {err}"
)
.inspect_err(|err| {
debug!("Failed to format embedded code for parser '{parser_name}': {err}");
})
},
)
Expand Down Expand Up @@ -303,7 +302,17 @@ impl ExternalFormatter {
})?;
let doc_jsons = doc_json_strs
.into_iter()
.map(|s| serde_json::from_str(&s))
.map(|s| {
// Prettier's Doc can produce deeply nested arrays.
// (e.g., md-in-js with `proseWrap: preserve`,
// which nests each word in `[[[prev, " "], word], " "]`)
// The default recursion limit of 128 is not enough for long paragraphs.
// This only affects this deserialization call;
// other `serde_json` usage in the codebase keeps the default limit.
let mut de = serde_json::Deserializer::from_str(&s);
de.disable_recursion_limit();
serde_json::Value::deserialize(&mut de)
})
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to parse Doc JSON: {e}"))?;

Expand All @@ -314,6 +323,9 @@ impl ExternalFormatter {
group_id_builder,
)
})
.inspect_err(|err| {
debug!("Failed to format embedded doc for parser '{parser_name}': {err}");
})
}))
} else {
None
Expand Down
145 changes: 128 additions & 17 deletions apps/oxfmt/src/prettier_compat/from_prettier_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ pub fn to_format_elements_for_template<'a>(
.map(|envelope| {
let (mut ir, _) = convert(envelope, allocator, group_id_builder)?;
postprocess(
&mut ir, allocator,
&mut ir,
allocator,
// GraphQL uses `.cooked` values, so template chars need escaping
true, None,
TemplateEscape::Full,
None,
);
Ok(ir)
})
Expand All @@ -75,7 +77,7 @@ pub fn to_format_elements_for_template<'a>(
&mut ir,
allocator,
// CSS uses `.raw` values, so no template char escaping needed
false,
TemplateEscape::None,
Some(("@prettier-placeholder-", "-id")),
);
Ok(EmbeddedDocResult::DocWithPlaceholders {
Expand All @@ -96,7 +98,7 @@ pub fn to_format_elements_for_template<'a>(
&mut ir,
allocator,
// HTML/Angular use `.cooked` values, so template chars need escaping
true,
TemplateEscape::Full,
Some(("PRETTIER_HTML_PLACEHOLDER_", "_IN_JS")),
);
Ok(EmbeddedDocResult::DocWithPlaceholders {
Expand All @@ -105,6 +107,21 @@ pub fn to_format_elements_for_template<'a>(
html_has_multiple_root_elements,
})
}
"tagged-markdown" => {
let (mut ir, _) = convert(
doc_jsons.into_iter().next().expect("Doc JSON for Markdown"),
allocator,
group_id_builder,
)?;
postprocess(
&mut ir,
allocator,
// Markdown uses `.raw` values with backtick unescaping on Rust side
TemplateEscape::RawBacktick,
None,
);
Ok(EmbeddedDocResult::SingleDoc(ir))
}
_ => unreachable!("Unsupported embedded_doc language: {language}"),
}
}
Expand Down Expand Up @@ -273,10 +290,10 @@ fn convert_align<'a>(
}
out.push(FormatElement::Tag(Tag::EndDedent(DedentMode::Level)));
return Ok(());
} else if i > 0 && i <= 255 {
} else if i > 0 {
debug_assert!(i <= 255, "align value {i} exceeds NonZeroU8 range");
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let count = i as u8;
if let Some(nz) = NonZeroU8::new(count) {
if let Some(nz) = NonZeroU8::new(i as u8) {
out.push(FormatElement::Tag(Tag::StartAlign(Align::new(nz))));
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
Expand All @@ -296,6 +313,53 @@ fn convert_align<'a>(
out.push(FormatElement::Tag(Tag::EndDedent(DedentMode::Root)));
Ok(())
}
Value::String(s) => {
// String alignment (e.g., " " for markdown list continuation indent).
// Prettier uses the string length as the number of spaces to align by.
if s.is_empty() {
// Empty string → no alignment, just render contents
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
}
return Ok(());
}
debug_assert!(
s.len() <= 255,
"align string length {} exceeds NonZeroU8 range",
s.len()
);
#[expect(clippy::cast_possible_truncation)]
if let Some(nz) = NonZeroU8::new(s.len() as u8) {
out.push(FormatElement::Tag(Tag::StartAlign(Align::new(nz))));
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
}
out.push(FormatElement::Tag(Tag::EndAlign));
return Ok(());
}
Err(format!("Unsupported align value: {n}"))
}
Value::Object(obj_val) => {
// `align({type: "root"}, ...)` = Prettier's `markAsRoot()`.
// In Prettier, `markAsRoot` records the current indent position
// so that a later `dedentToRoot` can return to it.
// However, `oxc_formatter`'s `DedentMode::Root` always resets to absolute level 0
// and has no way to store a custom root position.
// Skipping the root capture is safe because
// embedded language Docs are processed in their own context starting near level 0,
// so `dedentToRoot` to absolute 0 produces the same result.
//
// NOTE: `markAsRoot` is used in Prettier for other cases.
// e.g. JS comment printer, YAML block printer, and front-matter embed.
// But none of those go through this Doc→IR path.
if obj_val.get("type").and_then(Value::as_str) == Some("root") {
if let Some(contents) = obj.get("contents") {
convert_doc(contents, out, ctx)?;
}
return Ok(());
}
Err(format!("Unsupported align value: {n}"))
}
_ => Err(format!("Unsupported align value: {n}")),
}
}
Expand Down Expand Up @@ -399,20 +463,28 @@ fn extract_group_id(

// ---

#[derive(Clone, Copy)]
enum TemplateEscape {
/// No escaping
None,
/// Full escaping: `\` → `\\`, `` ` `` → `` \` ``, `${` → `\${`.
Full,
/// Raw backtick escaping: `(\\*)\`` → `$1$1\\\``.
RawBacktick,
}

/// Post-process FormatElements in a single compaction pass:
/// - strip trailing hardline (useless for embedded parts)
/// - collapse double-hardlines `[Hard, ExpandParent, Hard, ExpandParent]` → `[Empty, ExpandParent]`
/// - merge consecutive Text nodes (SCSS emits split strings like `"@"` + `"prettier-placeholder-0-id"`)
/// - escape template characters (`\`, `` ` ``, `${`)
/// - for css-in-js, this is not needed because values are already escaped via `.raw`
/// - for others, `.cooked` is used, so escaping is needed
/// - escape template characters (mode determined by [`TemplateEscape`])
/// - count placeholders matching `(prefix)(digits)(_digits)?(suffix)` pattern
///
/// Returns the placeholder count (0 when `placeholder` is `None`).
fn postprocess<'a>(
ir: &mut Vec<FormatElement<'a>>,
allocator: &'a Allocator,
escape_template_chars: bool,
escape: TemplateEscape,
placeholder: Option<(&str, &str)>,
) -> usize {
// Strip trailing hardline
Expand Down Expand Up @@ -458,10 +530,10 @@ fn postprocess<'a>(
}
sb.into_str()
};
let text = if escape_template_chars {
escape_template_characters(text, allocator)
} else {
text
let text = match escape {
TemplateEscape::None => text,
TemplateEscape::Full => escape_template_characters(text, allocator),
TemplateEscape::RawBacktick => escape_backticks_raw_str(text, allocator),
};
let width = TextWidth::from_text(text, IndentWidth::default());
ir[write] = FormatElement::Text { text, width };
Expand Down Expand Up @@ -526,7 +598,9 @@ fn escape_template_characters<'a>(s: &'a str, allocator: &'a Allocator) -> &'a s
let bytes = s.as_bytes();
let len = bytes.len();

// Fast path: scan for characters that need escaping.
// Fast path: scan for the first character that needs escaping.
// All characters of interest (`\`, `` ` ``, `$`, `{`) are single-byte ASCII,
// so byte-indexed access is safe and avoids multi-byte decode overhead.
let first_escape = (0..len).find(|&i| {
let ch = bytes[i];
ch == b'\\' || ch == b'`' || (ch == b'$' && i + 1 < len && bytes[i + 1] == b'{')
Expand All @@ -536,7 +610,7 @@ fn escape_template_characters<'a>(s: &'a str, allocator: &'a Allocator) -> &'a s
return s;
};

// Slow path: build escaped string in the arena.
// Slow path: build escaped string in the arena, reusing the clean prefix.
let mut result = StringBuilder::with_capacity_in(len + 1, allocator);
result.push_str(&s[..first]);

Expand All @@ -557,3 +631,40 @@ fn escape_template_characters<'a>(s: &'a str, allocator: &'a Allocator) -> &'a s

result.into_str()
}

/// Escape backticks in raw mode for markdown-in-JS template literals.
///
/// Equivalent to Prettier's `escapeTemplateCharacters(doc, /* raw */ true)`:
/// <https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/print/template-literal.js#L277-L287>
/// `str.replaceAll(/(\\*)`/g, "$1$1\\`")`
///
/// For each backtick, doubles the preceding backslashes and adds `\` before the backtick:
/// - `` ` `` → `` \` ``
/// - `` \` `` → `` \\\` ``
/// - `` \\` `` → `` \\\\\` ``
fn escape_backticks_raw_str<'a>(s: &'a str, allocator: &'a Allocator) -> &'a str {
if !s.contains('`') {
return s;
}
let mut result = StringBuilder::with_capacity_in(s.len() + 1, allocator);
let mut bs_count: usize = 0;
for ch in s.chars() {
if ch == '\\' {
bs_count += 1;
result.push('\\');
} else if ch == '`' {
// The backslash branch already emitted `bs_count` backslashes.
// Emit another `bs_count` to double them, then add `\``.
for _ in 0..bs_count {
result.push('\\');
}
result.push('\\');
result.push('`');
bs_count = 0;
} else {
bs_count = 0;
result.push(ch);
}
}
result.into_str()
}
Loading
Loading