mirror of
https://github.com/aphrody-labs/oxc.git
synced 2026-10-15 14:49:36 +00:00
fix(codegen): preserve property key annotations (#25766)
`@__KEY__` and `#__KEY__` are currently treated as normal comments. Codegen can drop them before a later property-mangling pass. The property may then be renamed while the annotated string keeps its old name.
This classifies the marker as a property-key annotation. Codegen keeps it next to string and no-substitution template literals when annotation comments are enabled, and the property mangler reads that classification instead of scanning source text. Final output still follows the normal comment options, so whitespace-minified or annotation-disabled output removes the marker.
## Example
Input:
```js
const object = { _field: 1 };
const key = /* #__KEY__ */ "_field";
object[key];
```
Before, an intermediate codegen pass could remove the marker:
```js
const object = { _field: 1 };
const key = "_field";
object[key];
```
After, the marker survives for the later property-mangling pass.
Verified with the affected crate suites, `just minsize`, workspace lint, docs, AST generation, and independent review. `just ready` is only blocked locally by existing Node-version and terminal-color snapshot differences.
Implemented with AI assistance (OpenAI Codex). I reviewed and understand the changes and remain responsible for this contribution under the repository AI usage policy.
This commit is contained in:
1 parent
28c0493682
commit
8ab883afaa
11 files changed
+185
-12
No files matched your search
@@ -104,6 +104,11 @@ pub enum CommentContent {
|
||||
/// Classified separately because its meaning remains valid if the next AST
|
||||
/// node is removed, unlike position-sensitive coverage annotations.
|
||||
CoverageIgnoreFile = 11,
|
||||
|
||||
/// Marks the following string or no-substitution template as a property name.
|
||||
/// `/* @__KEY__ */` or `/* #__KEY__ */`
|
||||
/// <https://esbuild.github.io/api/#mangle-key>
|
||||
PropertyKey = 12,
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
@@ -277,6 +282,12 @@ impl Comment {
|
||||
self.content == CommentContent::NoSideEffects
|
||||
}
|
||||
|
||||
/// Is a leading `/* @__KEY__ */` or `/* #__KEY__ */` annotation.
|
||||
#[inline]
|
||||
pub fn is_property_key_annotation(self) -> bool {
|
||||
self.content == CommentContent::PropertyKey && self.is_leading()
|
||||
}
|
||||
|
||||
/// Is webpack magic comment.
|
||||
#[inline]
|
||||
pub fn is_webpack(self) -> bool {
|
||||
|
||||
@@ -101,6 +101,7 @@ impl Codegen<'_> {
|
||||
}
|
||||
}
|
||||
if add {
|
||||
self.has_property_key_annotations |= comment.is_property_key_annotation();
|
||||
if preserve_when_orphaned(*comment)
|
||||
&& let Err(idx) = self.orphan_comment_keys.binary_search(&comment.attached_to)
|
||||
{
|
||||
@@ -195,6 +196,19 @@ impl Codegen<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a property-key annotation attached directly to a string or template literal.
|
||||
#[inline]
|
||||
pub(crate) fn print_property_key_annotation(&mut self, start: u32) {
|
||||
if !self.has_property_key_annotations {
|
||||
return;
|
||||
}
|
||||
if self.comments.get(&start).is_some_and(|comments| {
|
||||
comments.iter().any(|comment| comment.is_property_key_annotation())
|
||||
}) {
|
||||
self.print_leading_comments_anchored_to_self(start);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print comments attached to an expression that survives codegen.
|
||||
///
|
||||
/// Probes the parenthesized layers too: `a || /* c */ (x)` anchors the
|
||||
|
||||
@@ -2342,6 +2342,9 @@ impl GenExpr for ImportExpression<'_> {
|
||||
|
||||
impl Gen for TemplateLiteral<'_> {
|
||||
fn r#gen(&self, p: &mut Codegen, _ctx: Context) {
|
||||
if self.is_no_substitution_template() {
|
||||
p.print_property_key_annotation(self.span.start);
|
||||
}
|
||||
p.add_source_mapping(self.span);
|
||||
p.print_ascii_byte(b'`');
|
||||
debug_assert_eq!(self.quasis.len(), self.expressions.len() + 1);
|
||||
|
||||
@@ -127,6 +127,7 @@ pub struct Codegen<'a> {
|
||||
|
||||
// Builders
|
||||
comments: CommentsMap,
|
||||
has_property_key_annotations: bool,
|
||||
|
||||
/// Pure / no-side-effects annotation comments keyed by `attached_to`,
|
||||
/// so the emission site can recover verbatim source text instead of a
|
||||
@@ -195,6 +196,7 @@ impl<'a> Codegen<'a> {
|
||||
indent: 0,
|
||||
quote: Quote::Double,
|
||||
comments: CommentsMap::default(),
|
||||
has_property_key_annotations: false,
|
||||
annotation_comments: FxHashMap::default(),
|
||||
orphan_comment_keys: Vec::new(),
|
||||
#[cfg(feature = "sourcemap")]
|
||||
|
||||
@@ -116,6 +116,7 @@ pub struct CommentOptions {
|
||||
/// * pure: `/* #__PURE__ */` and `/* #__NO_SIDE_EFFECTS__ */`
|
||||
/// * webpack: `/* webpackChunkName */`
|
||||
/// * vite: `/* @vite-ignore */`
|
||||
/// * property key: `/* @__KEY__ */` and `/* #__KEY__ */`
|
||||
/// * coverage: `v8 ignore`, `c8 ignore`, `node:coverage`, `istanbul ignore`
|
||||
///
|
||||
/// Default is `true`.
|
||||
|
||||
@@ -29,6 +29,7 @@ impl Quote {
|
||||
impl Codegen<'_> {
|
||||
/// Print a [`StringLiteral`].
|
||||
pub(crate) fn print_string_literal(&mut self, s: &StringLiteral<'_>, allow_backtick: bool) {
|
||||
self.print_property_key_annotation(s.span.start);
|
||||
self.add_source_mapping(s.span);
|
||||
self.print_string_impl(s.value.as_str(), s.lone_surrogates, allow_backtick);
|
||||
}
|
||||
@@ -38,6 +39,7 @@ impl Codegen<'_> {
|
||||
/// A template literal is never a directive, which a string literal in the first statement position would be.
|
||||
/// See `print_directives_and_statements`.
|
||||
pub(crate) fn print_string_literal_as_template(&mut self, s: &StringLiteral<'_>) {
|
||||
self.print_property_key_annotation(s.span.start);
|
||||
self.add_source_mapping(s.span);
|
||||
Quote::Backtick.print(self);
|
||||
self.print_string_body(s.value.as_str(), s.lone_surrogates, Some(Quote::Backtick), true);
|
||||
|
||||
@@ -71,6 +71,53 @@ fn test_comments_before_expression_operands_idempotency() {
|
||||
test_idempotency("const value = { aFunction: /* istanbul ignore next */ () => {} };");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property_key_annotations_before_literals() {
|
||||
use oxc_codegen::{CodegenOptions, CommentOptions};
|
||||
|
||||
let annotation_only = CodegenOptions {
|
||||
comments: CommentOptions { normal: false, ..CommentOptions::default() },
|
||||
..CodegenOptions::default()
|
||||
};
|
||||
let source = r#"const key = /* @__KEY__ */ "_field";
|
||||
const lineKey = // #__KEY__
|
||||
"_field";
|
||||
object[/* #__KEY__ */ `_field`];"#;
|
||||
crate::tester::test_options(
|
||||
source,
|
||||
r#"const key = /* @__KEY__ */ "_field";
|
||||
const lineKey = // #__KEY__
|
||||
"_field";
|
||||
object[/* #__KEY__ */ `_field`];
|
||||
"#,
|
||||
annotation_only.clone(),
|
||||
);
|
||||
crate::tester::test_options(
|
||||
"use(/* @__KEY__ */ \"_field\");",
|
||||
"use(\n\t/* @__KEY__ */\n\t\"_field\"\n);\n",
|
||||
annotation_only.clone(),
|
||||
);
|
||||
|
||||
let minify_with_annotations = CodegenOptions { minify: true, ..annotation_only.clone() };
|
||||
crate::tester::test_options(
|
||||
"(/* @__KEY__ */ \"_field\");",
|
||||
"/* @__KEY__ */`_field`;",
|
||||
minify_with_annotations.clone(),
|
||||
);
|
||||
|
||||
test_idempotency_options(source, &annotation_only);
|
||||
test_idempotency_options("(/* @__KEY__ */ \"_field\");", &minify_with_annotations);
|
||||
|
||||
crate::tester::test_options(
|
||||
"const key = /* @__KEY__ */ \"_field\";",
|
||||
"const key = \"_field\";\n",
|
||||
CodegenOptions {
|
||||
comments: CommentOptions { annotation: false, ..CommentOptions::default() },
|
||||
..CodegenOptions::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A mid-line comment group must not receive a full indent: `print_comments`
|
||||
// used to inject `indent` tabs before any group whose first comment was not
|
||||
// preceded by a newline, so indented emission sites (`key: /** c */ value`)
|
||||
|
||||
@@ -8,6 +8,7 @@ pub const CONTENT_WEBPACK: u8 = 6;
|
||||
pub const CONTENT_VITE: u8 = 7;
|
||||
pub const CONTENT_COVERAGE_IGNORE: u8 = 8;
|
||||
pub const CONTENT_COVERAGE_IGNORE_FILE: u8 = 9;
|
||||
pub const CONTENT_PROPERTY_KEY: u8 = 10;
|
||||
pub const META_MULTILINE: u8 = 0x10;
|
||||
|
||||
#[inline]
|
||||
@@ -23,6 +24,7 @@ pub fn content_from_ordinal(o: u8) -> oxc_ast::ast::CommentContent {
|
||||
CONTENT_VITE => CommentContent::Vite,
|
||||
CONTENT_COVERAGE_IGNORE => CommentContent::CoverageIgnore,
|
||||
CONTENT_COVERAGE_IGNORE_FILE => CommentContent::CoverageIgnoreFile,
|
||||
CONTENT_PROPERTY_KEY => CommentContent::PropertyKey,
|
||||
_ => CommentContent::None,
|
||||
}
|
||||
}
|
||||
@@ -63,6 +65,11 @@ fn has_nl_cr(bytes: &[u8]) -> bool {
|
||||
bytes.iter().any(|&b| b == b'\n' || b == b'\r')
|
||||
}
|
||||
|
||||
fn is_property_key_annotation(bytes: &[u8]) -> bool {
|
||||
std::str::from_utf8(bytes)
|
||||
.is_ok_and(|source| matches!(source.trim().strip_prefix(['@', '#']), Some("__KEY__")))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn classify(bytes: &[u8], is_block: bool, lic: bool) -> u8 {
|
||||
if bytes.is_empty() {
|
||||
@@ -87,6 +94,13 @@ fn classify(bytes: &[u8], is_block: bool, lic: bool) -> u8 {
|
||||
return CONTENT_NONE;
|
||||
}
|
||||
|
||||
let rest = &bytes[start..];
|
||||
if (rest.starts_with(b"@__KEY__") || rest.starts_with(b"#__KEY__") || !rest[0].is_ascii())
|
||||
&& is_property_key_annotation(bytes)
|
||||
{
|
||||
return CONTENT_PROPERTY_KEY;
|
||||
}
|
||||
|
||||
match bytes[start] {
|
||||
b'@' => {
|
||||
start += 1;
|
||||
@@ -176,7 +190,8 @@ pub fn meta_byte_exact(src: &[u8], start: u32, end: u32, is_block: bool) -> u8 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
CONTENT_COVERAGE_IGNORE, CONTENT_COVERAGE_IGNORE_FILE, classify, content_from_ordinal,
|
||||
CONTENT_COVERAGE_IGNORE, CONTENT_COVERAGE_IGNORE_FILE, CONTENT_NONE, CONTENT_PROPERTY_KEY,
|
||||
classify, content_from_ordinal,
|
||||
};
|
||||
use oxc_ast::ast::CommentContent;
|
||||
|
||||
@@ -205,4 +220,23 @@ mod tests {
|
||||
assert_eq!(classify(source, true, false), CONTENT_COVERAGE_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_key_annotation() {
|
||||
for source in [
|
||||
b"@__KEY__".as_slice(),
|
||||
b" #__KEY__ ",
|
||||
b"\t@__KEY__\n",
|
||||
"\u{a0}@__KEY__\u{a0}".as_bytes(),
|
||||
] {
|
||||
assert_eq!(classify(source, true, false), CONTENT_PROPERTY_KEY);
|
||||
assert_eq!(content_from_ordinal(CONTENT_PROPERTY_KEY), CommentContent::PropertyKey);
|
||||
}
|
||||
|
||||
for source in
|
||||
[b"__KEY__".as_slice(), b"@ __KEY__", b"@__key__", b"@__KEY___", b"@__KEY__ extra"]
|
||||
{
|
||||
assert_eq!(classify(source, true, false), CONTENT_NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,19 +161,11 @@ fn is_canonical_numeric_string(name: &str) -> bool {
|
||||
numeric_key_string(value) == name
|
||||
}
|
||||
|
||||
fn is_key_annotation(comment: &Comment, source_text: &str) -> bool {
|
||||
let text = comment.content_span().source_text(source_text).trim();
|
||||
matches!(text.strip_prefix(['@', '#']), Some("__KEY__"))
|
||||
}
|
||||
|
||||
fn key_annotated_spans(program: &Program<'_>) -> FxHashSet<u32> {
|
||||
program
|
||||
.comments
|
||||
.iter()
|
||||
.filter(|comment| {
|
||||
comment.position == CommentPosition::Leading
|
||||
&& is_key_annotation(comment, program.source_text)
|
||||
})
|
||||
.filter(|comment| comment.is_property_key_annotation())
|
||||
.map(|comment| comment.attached_to)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use oxc_allocator::Allocator;
|
||||
use oxc_ast_visit::VisitMut;
|
||||
use oxc_codegen::Codegen;
|
||||
use oxc_codegen::{Codegen, CodegenOptions, CommentOptions};
|
||||
use oxc_minifier::{
|
||||
CompressOptions, MangleOptions, ManglePropertiesOptions, ManglePropertyCache, Minifier,
|
||||
MinifierOptions, PropertyMangleCollection, PropertyMangler,
|
||||
@@ -143,6 +143,31 @@ fn annotations_override_quoted_behavior() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_key_annotations_survive_codegen_before_mangling() {
|
||||
let source = "const object = { _field: 1 }; const key = /* #__KEY__ */ '_field'; object[key];";
|
||||
let allocator = Allocator::default();
|
||||
let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse();
|
||||
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
|
||||
let rendered = Codegen::new()
|
||||
.with_options(CodegenOptions {
|
||||
comments: CommentOptions { normal: false, ..CommentOptions::default() },
|
||||
..CodegenOptions::default()
|
||||
})
|
||||
.build(&parsed.program)
|
||||
.code;
|
||||
assert!(rendered.contains("/* #__KEY__ */"));
|
||||
|
||||
let (actual, _) = mangle_with(&rendered, SourceType::mjs(), options("^_"));
|
||||
assert_eq!(
|
||||
actual,
|
||||
codegen(
|
||||
"const object = { e: 1 }; const key = /* #__KEY__ */ 'e'; object[key];",
|
||||
SourceType::mjs(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_annotations_do_not_rewrite_directives() {
|
||||
let source_type = SourceType::mjs();
|
||||
@@ -154,7 +179,7 @@ fn key_annotations_do_not_rewrite_directives() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_key_annotation_does_not_annotate_offset_zero() {
|
||||
fn property_key_annotation_without_a_literal_does_not_annotate_offset_zero() {
|
||||
let source = "'_x' in obj ? yes() : no();\nwork(); /* @__KEY__ */\ndone();";
|
||||
test(source, source, options("^_"));
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ impl<'a> TriviaBuilder<'a> {
|
||||
| CommentContent::Pure
|
||||
| CommentContent::PureNotApplied
|
||||
| CommentContent::NoSideEffects
|
||||
| CommentContent::PropertyKey
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,6 +277,14 @@ impl<'a> TriviaBuilder<'a> {
|
||||
return;
|
||||
}
|
||||
|
||||
let rest = &bytes[start..];
|
||||
if (rest.starts_with(b"@__KEY__") || rest.starts_with(b"#__KEY__") || !rest[0].is_ascii())
|
||||
&& is_property_key_annotation(s)
|
||||
{
|
||||
comment.content = CommentContent::PropertyKey;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast path: check first non-whitespace byte
|
||||
match bytes[start] {
|
||||
b'@' => {
|
||||
@@ -369,6 +378,11 @@ impl<'a> TriviaBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_property_key_annotation(source: &str) -> bool {
|
||||
matches!(source.trim().strip_prefix(['@', '#']), Some("__KEY__"))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn contains_license_or_preserve_comment(s: &str) -> bool {
|
||||
const LICENSE_LEN: usize = b"@license".len();
|
||||
@@ -634,6 +648,23 @@ function bar() {}";
|
||||
assert!(comments[0].is_pure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_key_comment_after_code_is_attached_to_next_literal() {
|
||||
for (source_text, literal) in [
|
||||
("work();/* #__KEY__ */\n\"_field\";", "\"_field\""),
|
||||
("work();// @__KEY__\n`_field`;", "`_field`"),
|
||||
] {
|
||||
let comments = get_comments(source_text);
|
||||
let literal_start = u32::try_from(source_text.find(literal).unwrap()).unwrap();
|
||||
|
||||
assert_eq!(comments.len(), 1);
|
||||
assert_eq!(comments[0].position, CommentPosition::Leading);
|
||||
assert_eq!(comments[0].attached_to, literal_start);
|
||||
assert!(comments[0].is_property_key_annotation());
|
||||
assert!(comments[0].is_annotation());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_comments_after_eq() {
|
||||
let source_text = "
|
||||
@@ -816,6 +847,17 @@ function bar() {}";
|
||||
("/* @__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffects),
|
||||
("/* #__PURE__ */", CommentContent::Pure),
|
||||
("/* #__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffects),
|
||||
("/* @__KEY__ */", CommentContent::PropertyKey),
|
||||
("/* #__KEY__ */", CommentContent::PropertyKey),
|
||||
("/*\u{a0}@__KEY__\u{a0}*/", CommentContent::PropertyKey),
|
||||
("//@__KEY__", CommentContent::PropertyKey),
|
||||
("// #__KEY__", CommentContent::PropertyKey),
|
||||
("/**\n * @__KEY__\n */", CommentContent::Jsdoc),
|
||||
("/* __KEY__ */", CommentContent::None),
|
||||
("/* @ __KEY__ */", CommentContent::None),
|
||||
("/* @__key__ */", CommentContent::None),
|
||||
("/* @__KEY___ */", CommentContent::None),
|
||||
("/* @__KEY__ extra */", CommentContent::None),
|
||||
("/* turbopackOptional: true */", CommentContent::Turbopack),
|
||||
("/* v8 ignore next */", CommentContent::CoverageIgnore),
|
||||
("/* v8 ignore filename */", CommentContent::CoverageIgnore),
|
||||
|
||||
Reference in new issue
Block a user