Skip to content

Commit 85a0677

Browse files
fix(glyph): consume split closing tags whole in whitespace-significant elements (#3268)
Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
1 parent b184e64 commit 85a0677

3 files changed

Lines changed: 115 additions & 29 deletions

File tree

crates/vize_glyph/src/template/formatter.rs

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use super::{
1919
},
2020
};
2121

22+
mod whitespace_significant;
23+
2224
/// High-performance template formatter.
2325
pub(crate) struct TemplateFormatter<'a> {
2426
options: &'a FormatOptions,
@@ -192,29 +194,17 @@ impl<'a> TemplateFormatter<'a> {
192194
pos = closing_end_pos;
193195
continue;
194196
} else if is_whitespace_significant_element(&tag_name, &sorted_attrs) {
195-
// `<pre>`, `<textarea>`, and any element with `v-pre`
196-
// are whitespace-significant. Their content must be
197-
// emitted byte-for-byte: a formatter must never
198-
// change rendered output. Find the matching close
199-
// tag and copy the inner source verbatim. (#963)
200-
output.push(b'>');
201-
if let Some(close_start) =
202-
find_matching_close_tag(source, end_pos, &tag_name)
203-
{
204-
output.extend_from_slice(&source[end_pos..close_start]);
205-
output.extend_from_slice(b"</");
206-
output.extend_from_slice(tag_name.as_bytes());
207-
output.push(b'>');
208-
output.extend_from_slice(self.newline);
209-
// Move past `</tag_name>`
210-
pos = close_start + 2 + tag_name.len() + 1;
211-
continue;
212-
} else {
213-
// Unclosed — copy the rest and stop.
214-
output.extend_from_slice(&source[end_pos..]);
215-
pos = len;
216-
continue;
217-
}
197+
// Copy `<pre>`/`<textarea>`/`v-pre` content verbatim so
198+
// the formatter never changes rendered output.
199+
// (#963, #3249)
200+
pos = self.copy_whitespace_significant_element(
201+
source,
202+
end_pos,
203+
&tag_name,
204+
len,
205+
&mut output,
206+
);
207+
continue;
218208
} else {
219209
output.push(b'>');
220210
if !is_void {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//! Verbatim emission of whitespace-significant elements (`<pre>`,
2+
//! `<textarea>`, and any element carrying `v-pre`). Split into its own file so
3+
//! the already-large `formatter.rs` stays within the source-file-length budget
4+
//! (#3251).
5+
6+
use super::{TemplateFormatter, find_matching_close_tag};
7+
8+
impl TemplateFormatter<'_> {
9+
/// Emit a whitespace-significant element verbatim, returning the source
10+
/// offset to resume formatting from.
11+
///
12+
/// The opening tag has already been rendered up to (but not including) its
13+
/// `>`; `end_pos` points just past that `>` in `source`. The element's
14+
/// content must be copied byte-for-byte because a formatter must never
15+
/// change rendered output. (#963)
16+
pub(super) fn copy_whitespace_significant_element(
17+
&self,
18+
source: &[u8],
19+
end_pos: usize,
20+
tag_name: &str,
21+
len: usize,
22+
output: &mut Vec<u8>,
23+
) -> usize {
24+
output.push(b'>');
25+
let Some(close_start) = find_matching_close_tag(source, end_pos, tag_name) else {
26+
// Unclosed — copy the rest and stop.
27+
output.extend_from_slice(&source[end_pos..]);
28+
return len;
29+
};
30+
output.extend_from_slice(&source[end_pos..close_start]);
31+
// If the closing tag is incomplete (no `>`, e.g. an unterminated LSP
32+
// buffer like `<pre>body</pre\n`), preserve the remaining source
33+
// verbatim instead of fabricating a `>` and dropping the tail.
34+
let Some(close_offset) = memchr::memchr(b'>', &source[close_start..]) else {
35+
output.extend_from_slice(&source[close_start..]);
36+
return len;
37+
};
38+
output.extend_from_slice(b"</");
39+
output.extend_from_slice(tag_name.as_bytes());
40+
output.push(b'>');
41+
output.extend_from_slice(self.newline);
42+
// The closing tag may carry whitespace before `>` (the Prettier
43+
// `</pre\n >` trick that keeps the trailing newline out of `<pre>`
44+
// content), so scan to the actual `>` rather than assuming a bare
45+
// `</tag_name>`. Skipping only the bare length would leave ` >` behind
46+
// as a stray text node and change the rendered output. (#3249)
47+
close_start + close_offset + 1
48+
}
49+
}
50+
51+
#[cfg(test)]
52+
mod tests {
53+
use crate::options::FormatOptions;
54+
use crate::template::format_template_content;
55+
56+
#[test]
57+
fn test_pre_split_closing_tag_leaves_no_stray_gt() {
58+
// A closing tag split across lines (`</pre\n >`, the Prettier trick
59+
// that keeps a trailing newline out of `<pre>` content) must be
60+
// consumed whole. Leaving the trailing `>` behind reprinted it as a
61+
// stray text node and changed the rendered output. (#3249)
62+
let options = FormatOptions::default();
63+
64+
let source = "<pre>\npage: {{ x }}</pre\n >";
65+
let result = format_template_content(source, &options).unwrap();
66+
assert_eq!(result.as_str(), "<pre>\npage: {{ x }}</pre>");
67+
assert_eq!(
68+
format_template_content(&result, &options).unwrap(),
69+
result,
70+
"collapsed close tag must be idempotent"
71+
);
72+
73+
// Same trick on `<textarea>`.
74+
let ta = "<textarea>value</textarea\n>";
75+
assert_eq!(
76+
format_template_content(ta, &options).unwrap().as_str(),
77+
"<textarea>value</textarea>"
78+
);
79+
80+
// A whitespace-significant element whose split close tag has no inner
81+
// content still round-trips cleanly.
82+
let empty_pre = "<pre></pre\n>";
83+
assert_eq!(
84+
format_template_content(empty_pre, &options)
85+
.unwrap()
86+
.as_str(),
87+
"<pre></pre>"
88+
);
89+
}
90+
91+
#[test]
92+
fn test_pre_ordinary_closing_tag_unaffected() {
93+
// Regression guard: a normal `</pre>` (no split) is unchanged and the
94+
// inner content stays byte-for-byte. (#3249)
95+
let options = FormatOptions::default();
96+
let source = "<pre>\n a\n b</pre>";
97+
assert_eq!(
98+
format_template_content(source, &options).unwrap().as_str(),
99+
source
100+
);
101+
}
102+
}

tests/_fixtures/glyph-corpus-known-violations.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,5 @@
118118
"project": "shadcn-vue",
119119
"path": "apps/v4/styles/reka-vega/ui/chart/ChartStyle.vue",
120120
"issue": "https://github.com/ubugeeei-prod/vize/issues/3247"
121-
},
122-
{
123-
"property": "parse-preservation",
124-
"project": "vue-router",
125-
"path": "packages/playground-file-based/src/pages/test-params/query.vue",
126-
"issue": "https://github.com/ubugeeei-prod/vize/issues/3249"
127121
}
128122
]

0 commit comments

Comments
 (0)