Skip to content

Commit 5186328

Browse files
committed
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.
1 parent d1dbd71 commit 5186328

22 files changed

Lines changed: 197 additions & 32 deletions

File tree

‎apps/oxlint/src-js/plugins/comments.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,16 +344,18 @@ function deserializeCommentIfNeeded(index: number): Comment | null {
344344
// Deserialize comment into a cached `Comment` object
345345
const comment = cachedComments[index];
346346

347-
const isBlock = commentsUint8[pos + COMMENT_KIND_OFFSET] !== COMMENT_LINE_KIND;
347+
// HTML comment discriminants equal their delimiter lengths (3 or 4).
348+
const kind = commentsUint8[pos + COMMENT_KIND_OFFSET];
349+
const isBlock = kind !== COMMENT_LINE_KIND && kind < 3;
348350

349351
const pos32 = pos >> 2,
350352
start = commentsInt32[pos32],
351353
end = commentsInt32[pos32 + 1];
352354

353355
comment.type = isBlock ? "Block" : "Line";
354-
// Line comments: `// text` -> slice `start + 2..end`
356+
// Line comments: skip `//`, `-->`, or `<!--`
355357
// Block comments: `/* text */` -> slice `start + 2..end - 2`
356-
comment.value = sourceText.slice(start + 2, end - (+isBlock << 1));
358+
comment.value = sourceText.slice(start + (kind < 3 ? 2 : kind), end - (+isBlock << 1));
357359
comment.range[0] = comment.start = start;
358360
comment.range[1] = comment.end = end;
359361

‎apps/oxlint/test/tokens.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,23 @@ const CallExpression = { range: [48, 54] } as Node;
9696

9797
// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L62-L155
9898
describe("when calling getTokens", () => {
99+
it("should strip HTML comment markers and preserve comment ranges", () => {
100+
const source = "<!--a\n-->\n<!--\n//b\n/*c*/\nfoo;";
101+
setup(source);
102+
const program = { range: [0, source.length] } as Node;
103+
const comments = getTokens(program, {
104+
includeComments: true,
105+
filter: (token) => token.type === "Line" || token.type === "Block",
106+
});
107+
expect(comments.map(({ type, value, range }) => ({ type, value, range }))).toEqual([
108+
{ type: "Line", value: "a", range: [0, 5] },
109+
{ type: "Line", value: "", range: [6, 9] },
110+
{ type: "Line", value: "", range: [10, 14] },
111+
{ type: "Line", value: "b", range: [15, 18] },
112+
{ type: "Block", value: "c", range: [19, 24] },
113+
]);
114+
});
115+
99116
it("should retrieve all tokens for root node", () => {
100117
expect(getTokens(Program).map((token) => token.value)).toEqual([
101118
"var",

‎crates/oxc_ast/src/ast/comment.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ pub enum CommentKind {
2020
/// Multi-line block comment (contains line breaks)
2121
#[estree(rename = "Block")]
2222
MultiLineBlock = 2,
23+
/// HTML opening line comment (`<!--`). The discriminant is the delimiter length.
24+
#[estree(rename = "Line")]
25+
HtmlOpen = 4,
26+
/// HTML closing line comment (`-->`). The discriminant is the delimiter length.
27+
#[estree(rename = "Line")]
28+
HtmlClose = 3,
2329
}
2430

2531
/// Information about a comment's position relative to a token.
@@ -212,6 +218,9 @@ impl Comment {
212218
pub fn content_span(&self) -> Span {
213219
match self.kind {
214220
CommentKind::Line => Span::new(self.span.start + 2, self.span.end),
221+
CommentKind::HtmlOpen | CommentKind::HtmlClose => {
222+
Span::new(self.span.start + self.kind as u32, self.span.end)
223+
}
215224
CommentKind::SingleLineBlock | CommentKind::MultiLineBlock => {
216225
Span::new(self.span.start + 2, self.span.end - 2)
217226
}
@@ -221,7 +230,7 @@ impl Comment {
221230
/// Returns `true` if this is a line comment.
222231
#[inline]
223232
pub fn is_line(self) -> bool {
224-
self.kind == CommentKind::Line
233+
matches!(self.kind, CommentKind::Line | CommentKind::HtmlOpen | CommentKind::HtmlClose)
225234
}
226235

227236
/// Returns `true` if this is a block comment (either single-line or multi-line).

‎crates/oxc_ast/src/generated/derive_estree.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3368,6 +3368,8 @@ impl ESTree for CommentKind {
33683368
Self::Line => JsonSafeString("Line").serialize(serializer),
33693369
Self::SingleLineBlock => JsonSafeString("Block").serialize(serializer),
33703370
Self::MultiLineBlock => JsonSafeString("Block").serialize(serializer),
3371+
Self::HtmlOpen => JsonSafeString("Line").serialize(serializer),
3372+
Self::HtmlClose => JsonSafeString("Line").serialize(serializer),
33713373
}
33723374
}
33733375
}

‎crates/oxc_ast/src/serialize/mod.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,11 @@ fn get_ts_start_span(program: &Program<'_>) -> u32 {
218218
#[ast_meta]
219219
#[estree(
220220
ts_type = "string",
221-
raw_deser = "SOURCE_TEXT.slice(THIS.start + 2, THIS.end - (THIS.type === 'Line' ? 0 : 2))",
221+
raw_deser = "
222+
// HTML comment discriminants equal their delimiter lengths (3 or 4).
223+
const kind = DESER[u8](POS_OFFSET.kind);
224+
SOURCE_TEXT.slice(THIS.start + (kind < 3 ? 2 : kind), THIS.end - (THIS.type === 'Line' ? 0 : 2))
225+
",
222226
raw_deser_inline
223227
)]
224228
pub struct CommentValue<'b>(#[expect(dead_code)] pub &'b Comment);

‎crates/oxc_codegen/src/comment.rs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,26 @@ impl Codegen<'_> {
416416
};
417417
let comment_source = comment.span.source_text(source_text);
418418
match comment.kind {
419-
CommentKind::Line | CommentKind::SingleLineBlock => {
419+
CommentKind::Line | CommentKind::HtmlOpen | CommentKind::SingleLineBlock => {
420420
self.print_str_escaping_script_close_tag(comment_source);
421421
}
422+
CommentKind::HtmlClose => {
423+
// `-->` is only a comment at the start of a line, allowing indentation.
424+
// Annotation emission can place it after code even without minification.
425+
let is_mid_line = self
426+
.code()
427+
.as_bytes()
428+
.iter()
429+
.rfind(|&&byte| !matches!(byte, b' ' | b'\t'))
430+
.is_some_and(|&byte| byte != b'\n');
431+
let text = if is_mid_line {
432+
self.print_str("//");
433+
comment.content_span().source_text(source_text)
434+
} else {
435+
comment_source
436+
};
437+
self.print_str_escaping_script_close_tag(text);
438+
}
422439
CommentKind::MultiLineBlock => {
423440
for line in LineTerminatorSplitter::new(comment_source) {
424441
if !line.starts_with("/*") {

‎crates/oxc_codegen/tests/integration/comments.rs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,38 @@ fn test_comment_at_top_of_file() {
241241
assert_eq!(code, "/** comment */ export {};\n");
242242
}
243243

244+
#[test]
245+
fn test_html_closing_annotation_after_code() {
246+
use oxc_allocator::Allocator;
247+
use oxc_codegen::{Codegen, CodegenOptions};
248+
use oxc_parser::Parser;
249+
use oxc_span::SourceType;
250+
251+
let allocator = Allocator::default();
252+
let source_type = SourceType::script();
253+
for (source, pretty_delimiter, minified_delimiter) in [
254+
("foo();\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "-->", "//"),
255+
("if (true)\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "//", "//"),
256+
("label:\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "//", "//"),
257+
("--> @__NO_SIDE_EFFECTS__\nfunction f() {}", "-->", "-->"),
258+
("{\n--> @__NO_SIDE_EFFECTS__\nfunction f() {}\n}", "-->", "//"),
259+
] {
260+
let ret = Parser::new(&allocator, source, source_type).parse();
261+
assert!(ret.diagnostics.is_empty(), "Invalid source: {source}");
262+
263+
for minify in [false, true] {
264+
let options = CodegenOptions { minify, ..CodegenOptions::default() };
265+
let code = Codegen::new().with_options(options.clone()).build(&ret.program).code;
266+
let reparsed = Parser::new(&allocator, &code, source_type).parse();
267+
assert!(reparsed.diagnostics.is_empty(), "Invalid output: {code}");
268+
let expected_comment = if minify { minified_delimiter } else { pretty_delimiter };
269+
assert!(code.contains(&format!("{expected_comment} @__NO_SIDE_EFFECTS__\n")));
270+
let second = Codegen::new().with_options(options).build(&reparsed.program).code;
271+
assert_eq!(code, second);
272+
}
273+
}
274+
}
275+
244276
#[test]
245277
fn unit() {
246278
test_same("<div>{/* Hello */}</div>;\n");

‎crates/oxc_lexer/src/pipeline/carve/common.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use oxc_ast::CommentKind;
2+
13
use crate::{comment_meta, error::DiagCode, lanes::Lanes, token::tk};
24

35
use crate::pipeline::{
@@ -290,6 +292,7 @@ pub(super) unsafe fn lex_html_open_comment(
290292
let m = comment_meta::meta_byte_exact(&srcs[..n], (s + 2) as u32, end as u32, false);
291293
lanes.comment_meta.push(m);
292294
lanes.push_comment_record(srcs, n, s as u32, end as u32, false, m);
295+
lanes.comments.last_mut().unwrap().kind = CommentKind::HtmlOpen;
293296
end
294297
}
295298

@@ -334,6 +337,7 @@ pub(super) unsafe fn lex_html_close_comment(
334337
let m = comment_meta::meta_byte_exact(&srcs[..n], (start + 1) as u32, end as u32, false);
335338
lanes.comment_meta.push(m);
336339
lanes.push_comment_record(srcs, n, start as u32, end as u32, false, m);
340+
lanes.comments.last_mut().unwrap().kind = CommentKind::HtmlClose;
337341
end
338342
}
339343

‎crates/oxc_lexer/tests/module_goal.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ fn script_html_open_comment_anywhere() {
3838
assert!(diag_codes("x <!-- y\nz;", false).is_empty());
3939
}
4040

41+
#[test]
42+
fn html_comment_content() {
43+
let source = "<!--a\n-->\n<!--";
44+
let mut buf = source.as_bytes().to_vec();
45+
buf.resize(buf.len() + PAD, 0);
46+
let options = LexOptions { source_type_module: false, ..Default::default() };
47+
let (result, arena) = lex_utf8(&buf, source.len() as u32, options);
48+
assert!(result.diagnostics().is_empty());
49+
let comments = result.comments(&arena);
50+
assert_eq!(comments.len(), 3);
51+
for (comment, content) in comments.iter().zip(["a", "", ""]) {
52+
assert!(comment.is_line());
53+
assert_eq!(comment.content_span().source_text(source), content);
54+
}
55+
}
56+
4157
#[test]
4258
fn module_html_open_comment_mid_expression_is_lt() {
4359
let ks = kinds_of("x <!-- y;", true);

‎crates/oxc_napi/src/lib.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ pub fn convert_utf8_to_utf16(
3434
}
3535
Comment {
3636
r#type: match comment.kind {
37-
CommentKind::Line => String::from("Line"),
37+
CommentKind::Line | CommentKind::HtmlOpen | CommentKind::HtmlClose => {
38+
String::from("Line")
39+
}
3840
CommentKind::SingleLineBlock | CommentKind::MultiLineBlock => {
3941
String::from("Block")
4042
}

0 commit comments

Comments
 (0)