fix(parser): handle HTML comment values (#22933)

Strip full HTML comment markers while preserving comment ranges, and keep closing annotations valid when emitted mid-line.

Fixes #22803.

AI-assisted with Codex.
This commit is contained in:
Boshen committed 2026-09-25 04:19:42 +00:00
1 parent d1dbd7187a
commit 5186328763
22 files changed
+197 -32

No files matched your search

+5 -3
View File
@@ -344,16 +344,18 @@ function deserializeCommentIfNeeded(index: number): Comment | null {
// Deserialize comment into a cached `Comment` object
const comment = cachedComments[index];
const isBlock = commentsUint8[pos + COMMENT_KIND_OFFSET] !== COMMENT_LINE_KIND;
// HTML comment discriminants equal their delimiter lengths (3 or 4).
const kind = commentsUint8[pos + COMMENT_KIND_OFFSET];
const isBlock = kind !== COMMENT_LINE_KIND && kind < 3;
const pos32 = pos >> 2,
start = commentsInt32[pos32],
end = commentsInt32[pos32 + 1];
comment.type = isBlock ? "Block" : "Line";
// Line comments: `// text` -> slice `start + 2..end`
// Line comments: skip `//`, `-->`, or `<!--`
// Block comments: `/* text */` -> slice `start + 2..end - 2`
comment.value = sourceText.slice(start + 2, end - (+isBlock << 1));
comment.value = sourceText.slice(start + (kind < 3 ? 2 : kind), end - (+isBlock << 1));
comment.range[0] = comment.start = start;
comment.range[1] = comment.end = end;
+17
View File
@@ -96,6 +96,23 @@ const CallExpression = { range: [48, 54] } as Node;
// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L62-L155
describe("when calling getTokens", () => {
it("should strip HTML comment markers and preserve comment ranges", () => {
const source = "<!--a\n-->\n<!--\n//b\n/*c*/\nfoo;";
setup(source);
const program = { range: [0, source.length] } as Node;
const comments = getTokens(program, {
includeComments: true,
filter: (token) => token.type === "Line" || token.type === "Block",
});
expect(comments.map(({ type, value, range }) => ({ type, value, range }))).toEqual([
{ type: "Line", value: "a", range: [0, 5] },
{ type: "Line", value: "", range: [6, 9] },
{ type: "Line", value: "", range: [10, 14] },
{ type: "Line", value: "b", range: [15, 18] },
{ type: "Block", value: "c", range: [19, 24] },
]);
});
it("should retrieve all tokens for root node", () => {
expect(getTokens(Program).map((token) => token.value)).toEqual([
"var",
+10 -1
View File
@@ -20,6 +20,12 @@ pub enum CommentKind {
/// Multi-line block comment (contains line breaks)
#[estree(rename = "Block")]
MultiLineBlock = 2,
/// HTML opening line comment (`<!--`). The discriminant is the delimiter length.
#[estree(rename = "Line")]
HtmlOpen = 4,
/// HTML closing line comment (`-->`). The discriminant is the delimiter length.
#[estree(rename = "Line")]
HtmlClose = 3,
}
/// Information about a comment's position relative to a token.
@@ -212,6 +218,9 @@ impl Comment {
pub fn content_span(&self) -> Span {
match self.kind {
CommentKind::Line => Span::new(self.span.start + 2, self.span.end),
CommentKind::HtmlOpen | CommentKind::HtmlClose => {
Span::new(self.span.start + self.kind as u32, self.span.end)
}
CommentKind::SingleLineBlock | CommentKind::MultiLineBlock => {
Span::new(self.span.start + 2, self.span.end - 2)
}
@@ -221,7 +230,7 @@ impl Comment {
/// Returns `true` if this is a line comment.
#[inline]
pub fn is_line(self) -> bool {
self.kind == CommentKind::Line
matches!(self.kind, CommentKind::Line | CommentKind::HtmlOpen | CommentKind::HtmlClose)
}
/// Returns `true` if this is a block comment (either single-line or multi-line).
@@ -3368,6 +3368,8 @@ impl ESTree for CommentKind {
Self::Line => JsonSafeString("Line").serialize(serializer),
Self::SingleLineBlock => JsonSafeString("Block").serialize(serializer),
Self::MultiLineBlock => JsonSafeString("Block").serialize(serializer),
Self::HtmlOpen => JsonSafeString("Line").serialize(serializer),
Self::HtmlClose => JsonSafeString("Line").serialize(serializer),
}
}
}
+5 -1
View File
@@ -218,7 +218,11 @@ fn get_ts_start_span(program: &Program<'_>) -> u32 {
#[ast_meta]
#[estree(
ts_type = "string",
raw_deser = "SOURCE_TEXT.slice(THIS.start + 2, THIS.end - (THIS.type === 'Line' ? 0 : 2))",
raw_deser = "
// HTML comment discriminants equal their delimiter lengths (3 or 4).
const kind = DESER[u8](POS_OFFSET.kind);
SOURCE_TEXT.slice(THIS.start + (kind < 3 ? 2 : kind), THIS.end - (THIS.type === 'Line' ? 0 : 2))
",
raw_deser_inline
)]
pub struct CommentValue<'b>(#[expect(dead_code)] pub &'b Comment);
+18 -1
View File
@@ -416,9 +416,26 @@ impl Codegen<'_> {
};
let comment_source = comment.span.source_text(source_text);
match comment.kind {
CommentKind::Line | CommentKind::SingleLineBlock => {
CommentKind::Line | CommentKind::HtmlOpen | CommentKind::SingleLineBlock => {
self.print_str_escaping_script_close_tag(comment_source);
}
CommentKind::HtmlClose => {
// `-->` is only a comment at the start of a line, allowing indentation.
// Annotation emission can place it after code even without minification.
let is_mid_line = self
.code()
.as_bytes()
.iter()
.rfind(|&&byte| !matches!(byte, b' ' | b'\t'))
.is_some_and(|&byte| byte != b'\n');
let text = if is_mid_line {
self.print_str("//");
comment.content_span().source_text(source_text)
} else {
comment_source
};
self.print_str_escaping_script_close_tag(text);
}
CommentKind::MultiLineBlock => {
for line in LineTerminatorSplitter::new(comment_source) {
if !line.starts_with("/*") {
@@ -241,6 +241,38 @@ fn test_comment_at_top_of_file() {
assert_eq!(code, "/** comment */ export {};\n");
}
#[test]
fn test_html_closing_annotation_after_code() {
use oxc_allocator::Allocator;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_parser::Parser;
use oxc_span::SourceType;
let allocator = Allocator::default();
let source_type = SourceType::script();
for (source, pretty_delimiter, minified_delimiter) in [
("foo();\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "-->", "//"),
("if (true)\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "//", "//"),
("label:\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "//", "//"),
("--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "-->", "-->"),
("{\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}\n}", "-->", "//"),
] {
let ret = Parser::new(&allocator, source, source_type).parse();
assert!(ret.diagnostics.is_empty(), "Invalid source: {source}");
for minify in [false, true] {
let options = CodegenOptions { minify, ..CodegenOptions::default() };
let code = Codegen::new().with_options(options.clone()).build(&ret.program).code;
let reparsed = Parser::new(&allocator, &code, source_type).parse();
assert!(reparsed.diagnostics.is_empty(), "Invalid output: {code}");
let expected_comment = if minify { minified_delimiter } else { pretty_delimiter };
assert!(code.contains(&format!("{expected_comment} @__NO_SIDE_EFFECTS__\n")));
let second = Codegen::new().with_options(options).build(&reparsed.program).code;
assert_eq!(code, second);
}
}
}
#[test]
fn unit() {
test_same("<div>{/* Hello */}</div>;\n");
@@ -1,3 +1,5 @@
use oxc_ast::CommentKind;
use crate::{comment_meta, error::DiagCode, lanes::Lanes, token::tk};
use crate::pipeline::{
@@ -290,6 +292,7 @@ pub(super) unsafe fn lex_html_open_comment(
let m = comment_meta::meta_byte_exact(&srcs[..n], (s + 2) as u32, end as u32, false);
lanes.comment_meta.push(m);
lanes.push_comment_record(srcs, n, s as u32, end as u32, false, m);
lanes.comments.last_mut().unwrap().kind = CommentKind::HtmlOpen;
end
}
@@ -334,6 +337,7 @@ pub(super) unsafe fn lex_html_close_comment(
let m = comment_meta::meta_byte_exact(&srcs[..n], (start + 1) as u32, end as u32, false);
lanes.comment_meta.push(m);
lanes.push_comment_record(srcs, n, start as u32, end as u32, false, m);
lanes.comments.last_mut().unwrap().kind = CommentKind::HtmlClose;
end
}
+16
View File
@@ -38,6 +38,22 @@ fn script_html_open_comment_anywhere() {
assert!(diag_codes("x <!-- y\nz;", false).is_empty());
}
#[test]
fn html_comment_content() {
let source = "<!--a\n-->\n<!--";
let mut buf = source.as_bytes().to_vec();
buf.resize(buf.len() + PAD, 0);
let options = LexOptions { source_type_module: false, ..Default::default() };
let (result, arena) = lex_utf8(&buf, source.len() as u32, options);
assert!(result.diagnostics().is_empty());
let comments = result.comments(&arena);
assert_eq!(comments.len(), 3);
for (comment, content) in comments.iter().zip(["a", "", ""]) {
assert!(comment.is_line());
assert_eq!(comment.content_span().source_text(source), content);
}
}
#[test]
fn module_html_open_comment_mid_expression_is_lt() {
let ks = kinds_of("x <!-- y;", true);
+3 -1
View File
@@ -34,7 +34,9 @@ pub fn convert_utf8_to_utf16(
}
Comment {
r#type: match comment.kind {
CommentKind::Line => String::from("Line"),
CommentKind::Line | CommentKind::HtmlOpen | CommentKind::HtmlClose => {
String::from("Line")
}
CommentKind::SingleLineBlock | CommentKind::MultiLineBlock => {
String::from("Block")
}
+4 -3
View File
@@ -1,3 +1,4 @@
use oxc_ast::CommentKind;
use oxc_data_structures::assert_unchecked;
use crate::{
@@ -304,7 +305,7 @@ ascii_byte_handler!(COM(lexer) {
// -
ascii_byte_handler!(MIN(lexer) {
lexer.consume_char();
lexer.read_minus().unwrap_or_else(|| lexer.skip_single_line_comment())
lexer.read_minus().unwrap_or_else(|| lexer.skip_single_line_comment(CommentKind::HtmlClose))
});
// .
@@ -319,7 +320,7 @@ ascii_byte_handler!(SLH(lexer) {
match lexer.peek_byte() {
Some(b'/') => {
lexer.consume_char();
lexer.skip_single_line_comment()
lexer.skip_single_line_comment(CommentKind::Line)
}
Some(b'*') => {
lexer.consume_char();
@@ -363,7 +364,7 @@ ascii_byte_handler!(SEM(lexer) {
// <
ascii_byte_handler!(LSS(lexer) {
lexer.consume_char();
lexer.read_left_angle().unwrap_or_else(|| lexer.skip_single_line_comment())
lexer.read_left_angle().unwrap_or_else(|| lexer.skip_single_line_comment(CommentKind::HtmlOpen))
});
// =
+4 -4
View File
@@ -25,7 +25,7 @@ static MULTILINE_COMMENT_START_TABLE: SafeByteMatchTable =
impl<'a, C: Config> Lexer<'a, C> {
/// Section 12.4 Single Line Comment
pub(super) fn skip_single_line_comment(&mut self) -> Kind {
pub(super) fn skip_single_line_comment(&mut self, kind: CommentKind) -> Kind {
byte_search! {
lexer: self,
table: LINE_BREAK_TABLE,
@@ -38,7 +38,7 @@ impl<'a, C: Config> Lexer<'a, C> {
if next_byte != LS_OR_PS_FIRST {
// `\r` or `\n`
self.trivia_builder
.add_line_comment(self.token.start(), self.source.offset_of(pos), self.source.whole());
.add_line_comment(self.token.start(), self.source.offset_of(pos), kind, self.source.whole());
// SAFETY: Safe to consume `\r` or `\n` as both are ASCII
pos = unsafe { pos.add(1) };
// We've found the end. Do not continue searching.
@@ -53,7 +53,7 @@ impl<'a, C: Config> Lexer<'a, C> {
if matches!(next2, LS_BYTES_2_AND_3 | PS_BYTES_2_AND_3) {
// Irregular line break
self.trivia_builder
.add_line_comment(self.token.start(), self.source.offset_of(pos), self.source.whole());
.add_line_comment(self.token.start(), self.source.offset_of(pos), kind, self.source.whole());
// Advance `pos` to after this char.
// SAFETY: `0xE2` is always 1st byte of a 3-byte UTF-8 char,
// so consuming 3 bytes will place `pos` on next UTF-8 char boundary.
@@ -71,7 +71,7 @@ impl<'a, C: Config> Lexer<'a, C> {
}
},
handle_eof: {
self.trivia_builder.add_line_comment(self.token.start(), self.offset(), self.source.whole());
self.trivia_builder.add_line_comment(self.token.start(), self.offset(), kind, self.source.whole());
return Kind::Skip;
},
};
+17 -2
View File
@@ -114,8 +114,8 @@ impl<'a> TriviaBuilder<'a> {
self.irregular_whitespaces.push(Span::new(start, end));
}
pub fn add_line_comment(&mut self, start: u32, end: u32, source_text: &str) {
self.add_comment(Comment::new(start, end, CommentKind::Line), source_text);
pub fn add_line_comment(&mut self, start: u32, end: u32, kind: CommentKind, source_text: &str) {
self.add_comment(Comment::new(start, end, kind), source_text);
}
pub fn add_block_comment(
@@ -666,6 +666,21 @@ token /* Trailing 1 */
assert_eq!(comments, expected);
}
#[test]
fn html_comment_content() {
let allocator = Allocator::default();
let source = "<!--a\n-->\n<!--";
let ret = Parser::new(&allocator, source, SourceType::script()).parse();
assert!(ret.diagnostics.is_empty());
assert_eq!(ret.program.comments.len(), 3);
let expected = [(Span::new(0, 5), "a"), (Span::new(6, 9), ""), (Span::new(10, 14), "")];
for (comment, (span, content)) in ret.program.comments.iter().zip(expected) {
assert!(comment.is_line());
assert_eq!(comment.span, span);
assert_eq!(comment.content_span().source_text(source), content);
}
}
#[test]
fn html_close_comments_after_irregular_line_terminators_are_leading() {
for line_terminator in ['\u{2028}', '\u{2029}'] {
@@ -4330,6 +4330,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -4338,10 +4342,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
};
@@ -4864,6 +4864,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -4872,10 +4876,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
};
@@ -4866,6 +4866,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -4874,10 +4878,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
range: [start, end],
@@ -5399,6 +5399,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -5407,10 +5411,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
range: [start, end],
@@ -4632,6 +4632,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -4640,10 +4644,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
};
@@ -5196,6 +5196,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -5204,10 +5208,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
};
@@ -5195,6 +5195,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -5203,10 +5207,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
range: [start, end],
@@ -5758,6 +5758,10 @@ function deserializeCommentKind(pos) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw Error(`Unexpected discriminant ${uint8[pos]} for CommentKind`);
}
@@ -5766,10 +5770,11 @@ function deserializeCommentKind(pos) {
function deserializeComment(pos) {
let type = deserializeCommentKind(pos + 12),
start = deserializeI32(pos),
end = deserializeI32(pos + 4);
end = deserializeI32(pos + 4),
kind = deserializeU8(pos + 12);
return {
type,
value: sourceText.slice(start + 2, end - (type === "Line" ? 0 : 2)),
value: sourceText.slice(start + (kind < 3 ? 2 : kind), end - (type === "Line" ? 0 : 2)),
start,
end,
range: [start, end],
@@ -12022,6 +12022,10 @@ function constructCommentKind(pos, ast) {
return "Block";
case 2:
return "Block";
case 3:
return "Line";
case 4:
return "Line";
default:
throw new Error(`Unexpected discriminant ${ast.buffer[pos]} for CommentKind`);
}