Skip to content

Commit 52b611b

Browse files
fix(oxfmt): use literalline for template literals in textToDoc
When `vueIndentScriptAndStyle: true`, Prettier wraps the `textToDoc()` result with `indent()`. Template literal newlines joined with `hardline` would respect this indent, causing +2 spaces per format pass (infinite indentation growth). Re-parse the formatted output to identify `TemplateElement` spans and use `literalline` (which ignores parent indent) for newlines within template literals, while keeping `hardline` for all other newlines. Closes #20084.
1 parent 2c0010a commit 52b611b

File tree

6 files changed

+152
-9
lines changed

6 files changed

+152
-9
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/oxfmt/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ doctest = false
2929
[dependencies]
3030
oxc_allocator = { workspace = true, features = ["pool"] }
3131
oxc_ast = { workspace = true }
32+
oxc_ast_visit = { workspace = true }
3233
oxc_data_structures = { workspace = true, features = ["rope"] }
3334
oxc_diagnostics = { workspace = true }
3435
oxc_formatter = { workspace = true }

apps/oxfmt/src/api/text_to_doc_api.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ pub fn run(
8686

8787
/// Full mode:
8888
/// - Format entire source as text
89-
/// - Return hardline-joined Doc string
89+
/// - Re-parse output to identify template literal spans
90+
/// - Return Doc using `literalline` for newlines inside template literals
9091
#[instrument(level = "debug", name = "oxfmt::text_to_doc::full", skip_all, fields(%source_ext))]
9192
fn run_full(
9293
source_ext: &str,
@@ -150,9 +151,10 @@ fn run_full(
150151
};
151152

152153
external_formatter.cleanup();
153-
Some(to_prettier_doc::printed_string_to_hardline_doc(&code))
154+
Some(to_prettier_doc::printed_string_to_doc_with_template_literals(
155+
&code, source_ext,
156+
))
154157
}
155-
156158
// ---
157159

158160
/// Fragment mode:

apps/oxfmt/src/prettier_compat/to_prettier_doc.rs

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
use rustc_hash::FxHashMap;
22
use serde_json::{Value, json};
33

4+
use oxc_allocator::Allocator;
5+
use oxc_ast::ast::TemplateElement;
6+
use oxc_ast_visit::Visit;
47
use oxc_formatter::{
58
BestFittingElement, DedentMode, FormatElement, GroupId, LineMode, PrintMode, Tag,
9+
get_parse_options,
610
};
11+
use oxc_parser::Parser;
12+
use oxc_span::{Span, SourceType};
713

814
// TODO: Currently, we build a Prettier Doc tree using `serde_json::Value`,
915
// then serialize it to a string.
@@ -13,8 +19,14 @@ use oxc_formatter::{
1319
// - caching interned elements
1420
// - reusing constant Doc structures like `{type: "line", hard: true}`
1521

16-
/// Splits a printed string by newlines and joins with Prettier `hardline` docs.
17-
pub fn printed_string_to_hardline_doc(text: &str) -> Value {
22+
/// Splits a printed string by newlines and joins with Prettier docs.
23+
/// Newlines inside template literal text use `literalline` (ignores parent indent),
24+
/// all other newlines use `hardline` (respects parent indent).
25+
///
26+
/// This is critical for `vueIndentScriptAndStyle: true` where Prettier wraps
27+
/// the `textToDoc()` result with `indent()`. Without `literalline`, template
28+
/// literal content gains extra indentation on each format pass (non-idempotent).
29+
pub fn printed_string_to_doc_with_template_literals(text: &str, source_ext: &str) -> Value {
1830
// `lines()` will remove trailing newlines, but it is fine.
1931
// For js-in-xxx fragments, do not need to preserve trailing newlines.
2032
let lines: Vec<&str> = text.lines().collect();
@@ -23,20 +35,94 @@ pub fn printed_string_to_hardline_doc(text: &str) -> Value {
2335
return Value::String(text.to_string());
2436
}
2537

38+
// Collect template literal element byte ranges from the formatted code.
39+
let template_literal_ranges = collect_template_element_spans(text, source_ext);
40+
41+
// Build the Doc, choosing `literalline` vs `hardline` per newline position.
2642
let mut parts: Vec<Value> = Vec::with_capacity(lines.len() * 3);
43+
let mut byte_offset: usize = 0;
44+
2745
for (i, line) in lines.iter().enumerate() {
2846
if 0 < i {
29-
// hardline = [{ type: "line", hard: true } + break-parent]
30-
// https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/document/builders/line.js#L27
31-
parts.push(json!({"type": "line", "hard": true}));
32-
parts.push(json!({"type": "break-parent"}));
47+
// The newline character is at `byte_offset - 1` (we advanced past it).
48+
// But we compute it as: the byte just after the previous line's content.
49+
let newline_pos = byte_offset;
50+
if is_in_template_literal(newline_pos, &template_literal_ranges) {
51+
// literalline = { type: "line", hard: true, literal: true } + break-parent
52+
push_literal_line(&mut parts);
53+
} else {
54+
// hardline = { type: "line", hard: true } + break-parent
55+
parts.push(json!({"type": "line", "hard": true}));
56+
parts.push(json!({"type": "break-parent"}));
57+
}
58+
// Account for the newline character(s)
59+
// Handle both \n and \r\n
60+
if byte_offset < text.len() && text.as_bytes()[byte_offset] == b'\r' {
61+
byte_offset += 1;
62+
}
63+
if byte_offset < text.len() && text.as_bytes()[byte_offset] == b'\n' {
64+
byte_offset += 1;
65+
}
3366
}
3467
parts.push(Value::String((*line).to_string()));
68+
byte_offset += line.len();
3569
}
3670

3771
normalize_array(parts)
3872
}
3973

74+
/// Check if a byte offset falls within any template literal element span.
75+
fn is_in_template_literal(pos: usize, ranges: &[Span]) -> bool {
76+
// Ranges are sorted, so binary search for efficiency.
77+
ranges.binary_search_by(|span| {
78+
let start = span.start as usize;
79+
let end = span.end as usize;
80+
if pos < start {
81+
std::cmp::Ordering::Greater
82+
} else if pos >= end {
83+
std::cmp::Ordering::Less
84+
} else {
85+
std::cmp::Ordering::Equal
86+
}
87+
}).is_ok()
88+
}
89+
90+
/// Parse the formatted code and collect all `TemplateElement` spans.
91+
/// These spans cover the raw text portions of template literals
92+
/// (between `\`` and `${` or `}` and `\``).
93+
fn collect_template_element_spans(text: &str, source_ext: &str) -> Vec<Span> {
94+
let Ok(source_type) = SourceType::from_extension(source_ext) else {
95+
return Vec::new();
96+
};
97+
98+
let allocator = Allocator::default();
99+
let ret = Parser::new(&allocator, text, source_type)
100+
.with_options(get_parse_options())
101+
.parse();
102+
103+
// If parsing fails, fall back to all-hardline (no literallines).
104+
if !ret.errors.is_empty() {
105+
return Vec::new();
106+
}
107+
108+
let mut visitor = TemplateElementCollector { spans: Vec::new() };
109+
visitor.visit_program(&ret.program);
110+
// Sort by start position for binary search.
111+
visitor.spans.sort_unstable_by_key(|s| s.start);
112+
visitor.spans
113+
}
114+
115+
/// AST visitor that collects spans of `TemplateElement` nodes.
116+
struct TemplateElementCollector {
117+
spans: Vec<Span>,
118+
}
119+
120+
impl<'a> Visit<'a> for TemplateElementCollector {
121+
fn visit_template_element(&mut self, elem: &TemplateElement<'a>) {
122+
self.spans.push(elem.span);
123+
}
124+
}
125+
40126
/// Converts `oxc_formatter` IR (`FormatElement` slice) into Prettier Doc.
41127
///
42128
/// This is used for js-in-xxx fragment formatting

apps/oxfmt/test/api/__snapshots__/js-in-vue.test.ts.snap

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`Format js-in-vue with prettier-plugin-oxfmt > should be idempotent for template literals with vueIndentScriptAndStyle 1`] = `
4+
"<script setup lang="ts">
5+
const msg = \`
6+
hello
7+
world
8+
\`;
9+
10+
function greet(name: string) {
11+
return \`
12+
Hello, \${name}!
13+
How are you?
14+
\`;
15+
}
16+
</script>
17+
<template>
18+
<div>{{ msg }}</div>
19+
</template>
20+
"
21+
`;
22+
323
exports[`Format js-in-vue with prettier-plugin-oxfmt > should format .vue w/ sort-imports 1`] = `
424
"<script lang="ts">
525
import a from "a";

apps/oxfmt/test/api/js-in-vue.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,37 @@ const cls = clsx("p-4 flex");
5252
expect(result.code).toMatchSnapshot();
5353
expect(result.errors).toStrictEqual([]);
5454
});
55+
56+
it("should be idempotent for template literals with vueIndentScriptAndStyle", async () => {
57+
const input = `
58+
<script setup lang="ts">
59+
const msg = \`
60+
hello
61+
world
62+
\`;
63+
64+
function greet(name: string) {
65+
return \`
66+
Hello, \${name}!
67+
How are you?
68+
\`;
69+
}
70+
</script>
71+
<template>
72+
<div>{{ msg }}</div>
73+
</template>
74+
`;
75+
const options = { vueIndentScriptAndStyle: true };
76+
77+
const first = await format("a.vue", input, options);
78+
expect(first.errors).toStrictEqual([]);
79+
80+
const second = await format("a.vue", first.code, options);
81+
expect(second.errors).toStrictEqual([]);
82+
83+
// Formatting should be idempotent: second pass should produce identical output.
84+
// Before the fix, each pass would add +2 spaces to template literal content.
85+
expect(second.code).toBe(first.code);
86+
expect(second.code).toMatchSnapshot();
87+
});
5588
});

0 commit comments

Comments
 (0)