diff --git a/.changeset/compiler-coverage-pragmas.md b/.changeset/compiler-coverage-pragmas.md new file mode 100644 index 000000000..e94dadd6c --- /dev/null +++ b/.changeset/compiler-coverage-pragmas.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +Preserve `/* istanbul ignore … */` and `/* c8 ignore … */` block comments written as JSX children on the generated component `children` getter. Line comments are not carried. diff --git a/packages/babel-plugin/src/shared/component.ts b/packages/babel-plugin/src/shared/component.ts index dbb3b37cb..c43b861ee 100644 --- a/packages/babel-plugin/src/shared/component.ts +++ b/packages/babel-plugin/src/shared/component.ts @@ -5,6 +5,7 @@ import { isDynamic, registerImportMethod, filterChildren, + getCoverageIgnoreComments, trimWhitespace, transformCondition, convertJSXIdentifier @@ -22,7 +23,7 @@ type ComponentTransformResult = TransformResult & { exprs: Array; }; -type ComponentChildrenResult = [t.Expression, boolean] | undefined; +type ComponentChildrenResult = [t.Expression, boolean, t.Comment[]?] | undefined; function isSimpleOptionalMemberExpression( expression: t.Expression | t.JSXEmptyExpression @@ -341,14 +342,14 @@ export default function transformComponent( : t.isFunction(childResult[0]) ? childResult[0].body : childResult[0]; - runningObject.push( - t.objectMethod( - "get", - t.identifier("children"), - [], - t.isExpression(body) ? t.blockStatement([t.returnStatement(body)]) : body - ) + const getter = t.objectMethod( + "get", + t.identifier("children"), + [], + t.isExpression(body) ? t.blockStatement([t.returnStatement(body)]) : body ); + if (childResult[2]?.length) getter.leadingComments = childResult[2]; + runningObject.push(getter); } else runningObject.push(t.objectProperty(t.identifier("children"), childResult[0])); } if (runningObject.length || !props.length) props.push(propsLiteral(runningObject)); @@ -409,9 +410,11 @@ function transformComponentChildren( if (!filteredChildren.length) return; let dynamic = false; let pathNodes: t.Node[] = []; + let coverageIgnoreComments: t.Comment[] | undefined; let transformedChildren: t.Expression | t.Expression[] = filteredChildren.reduce( (memo: t.Expression[], path: BabelPath) => { + coverageIgnoreComments ||= getCoverageIgnoreComments(path); if (t.isJSXText(path.node)) { const v = decode(trimWhitespace((path.node.extra?.raw as string | undefined) ?? "")); if (v.length) { @@ -468,5 +471,5 @@ function transformComponentChildren( transformedChildren = t.arrowFunctionExpression([], t.arrayExpression(transformedChildren)); dynamic = true; } - return [transformedChildren as t.Expression, dynamic]; + return [transformedChildren as t.Expression, dynamic, coverageIgnoreComments]; } diff --git a/packages/babel-plugin/src/shared/utils.ts b/packages/babel-plugin/src/shared/utils.ts index 714eba4eb..b66a23250 100644 --- a/packages/babel-plugin/src/shared/utils.ts +++ b/packages/babel-plugin/src/shared/utils.ts @@ -243,13 +243,38 @@ export function getStaticExpression( ); } +const coverageIgnoreCommentData = "solid.coverageIgnoreComments"; + +export function getCoverageIgnoreComments(path: NodePath): t.Comment[] | undefined { + return path.getData(coverageIgnoreCommentData) as t.Comment[] | undefined; +} + // remove unnecessary JSX Text nodes export function filterChildren(children: TPath[]): TPath[] { - return children.filter( - ({ node: child }) => - !(t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) && - (!t.isJSXText(child) || !/^[\r\n]\s*$/.test((child.extra?.raw as string | undefined) ?? "")) - ); + const filtered: TPath[] = []; + let pendingCoverageIgnoreComments: t.Comment[] = []; + + for (const path of children) { + const child = path.node; + if (t.isJSXExpressionContainer(child) && t.isJSXEmptyExpression(child.expression)) { + pendingCoverageIgnoreComments.push( + ...(child.expression.innerComments?.filter( + comment => + comment.type === "CommentBlock" && /^\s*(istanbul|c8)\s+ignore\b/.test(comment.value) + ) ?? []) + ); + continue; + } + if (t.isJSXText(child) && /^[\r\n]\s*$/.test((child.extra?.raw as string | undefined) ?? "")) { + continue; + } + if (pendingCoverageIgnoreComments.length) { + path.setData(coverageIgnoreCommentData, pendingCoverageIgnoreComments); + pendingCoverageIgnoreComments = []; + } + filtered.push(path); + } + return filtered; } export function checkLength(children: NodePath[]): boolean { diff --git a/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js new file mode 100644 index 000000000..45702a337 --- /dev/null +++ b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/code.js @@ -0,0 +1,111 @@ +export const istanbulPragma = ( + + {/* istanbul ignore next */} +
Hello
+
+); + +export const c8Pragma = ( + + {/* c8 ignore next */} +
Hello
+
+); + +export const expressionChild = ( + + {/* c8 ignore next */} + {state.value} + +); + +export const multipleChildren = ( + + {/* istanbul ignore next */} +
A
+ B +
+); + +export const nestedComponent = ( + + + {/* c8 ignore next */} +
A
+
+
+); + +export const twoContainers = ( + + {/* istanbul ignore next */} + {/* c8 ignore next */} +
A
+
+); + +export const noteBesidePragma = ( + + {/* note */ + /* c8 ignore next */} +
A
+
+); + +export const textAfterPragma = ( + + {/* c8 ignore next */}text
A
+
+); + +export const spaceAfterPragma = ( + + {/* c8 ignore next */}
A
+
+); + +export const lineComment = ( + + { + // c8 ignore next + } +
A
+
+); + +export const lineCommentMentioningBlock = ( + + { + // see /* c8 ignore next */ + } +
A
+
+); + +export const blockCommentMentioningLine = ( + + {/* see // c8 ignore next */} +
A
+
+); + +export const trailingPragma = ( + +
A
+ {/* c8 ignore next */} +
+); + +export const notePragmaOnly = ( + + {/* just a note */} +
A
+
+); + +export const lookalike = ( + + {/* c8 ignored */} +
A
+
+); diff --git a/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js new file mode 100644 index 000000000..1b00d6961 --- /dev/null +++ b/packages/babel-plugin/test/__shared_fixtures__/coveragePragmas/expected.js @@ -0,0 +1,39 @@ +// Shared by the Babel plugin and native compiler tests: the comments each +// export of `code.js` must print ahead of its `children` getters, in order. +const expected = { + istanbulPragma: ["/* istanbul ignore next */"], + c8Pragma: ["/* c8 ignore next */"], + expressionChild: ["/* c8 ignore next */"], + multipleChildren: ["/* istanbul ignore next */"], + nestedComponent: ["/* c8 ignore next */"], + twoContainers: ["/* istanbul ignore next */", "/* c8 ignore next */"], + noteBesidePragma: ["/* c8 ignore next */"], + textAfterPragma: ["/* c8 ignore next */"], + spaceAfterPragma: ["/* c8 ignore next */"], + lineComment: [], + lineCommentMentioningBlock: [], + blockCommentMentioningLine: [], + trailingPragma: [], + notePragmaOnly: [], + lookalike: [] +}; + +exports.expectedFor = function expectedFor(generate) { + // SSR prop hoisting moves a nested component's static props into a shared + // descriptor, whose `get()` does not carry the getter's comments. + return generate === "ssr" ? { ...expected, nestedComponent: [] } : expected; +}; + +const comment = String.raw`\/\*(?:(?!\*\/)[\s\S])*\*\/|\/\/[^\n]*`; +const getterComments = new RegExp(String.raw`((?:(?:${comment})\s*)+)get children\(\)`, "g"); + +exports.pragmasByExport = function pragmasByExport(code) { + const result = {}; + const sections = code.split(/^export const (\w+) = /m); + for (let i = 1; i < sections.length; i += 2) { + result[sections[i]] = [...sections[i + 1].matchAll(getterComments)].flatMap(([, group]) => + group.match(new RegExp(comment, "g")).map(text => text.trim()) + ); + } + return result; +}; diff --git a/packages/babel-plugin/test/coverage-pragmas.spec.js b/packages/babel-plugin/test/coverage-pragmas.spec.js new file mode 100644 index 000000000..f25ffb75e --- /dev/null +++ b/packages/babel-plugin/test/coverage-pragmas.spec.js @@ -0,0 +1,25 @@ +const babel = require("@babel/core"); +const fs = require("fs"); +const path = require("path"); +const plugin = require("../index"); +const { expectedFor, pragmasByExport } = require("./__shared_fixtures__/coveragePragmas/expected"); + +const fixture = fs.readFileSync( + path.join(__dirname, "__shared_fixtures__", "coveragePragmas", "code.js"), + "utf8" +); + +describe("coverage pragmas", () => { + test.each(["dom", "ssr", "universal"])( + "carries authored pragmas onto %s component children getters", + generate => { + const { code } = babel.transformSync(fixture, { + plugins: [[plugin, { generate, moduleName: "r-dom" }]], + configFile: false, + babelrc: false, + filename: "coveragePragmas.jsx" + }); + expect(pragmasByExport(code)).toEqual(expectedFor(generate)); + } + ); +}); diff --git a/packages/compiler/__tests__/transform.test.js b/packages/compiler/__tests__/transform.test.js index 86077f00e..1212487cb 100644 --- a/packages/compiler/__tests__/transform.test.js +++ b/packages/compiler/__tests__/transform.test.js @@ -3,6 +3,11 @@ const fs = require("fs"); const path = require("path"); const babelDomFixtures = path.resolve(__dirname, "../../babel-plugin/test/__dom_fixtures__"); +const coveragePragmasDir = path.resolve( + __dirname, + "../../babel-plugin/test/__shared_fixtures__/coveragePragmas" +); +const coveragePragmas = require(path.join(coveragePragmasDir, "expected")); function readFixture(name) { return fs.readFileSync(path.join(babelDomFixtures, name, "code.js"), "utf8"); @@ -89,6 +94,21 @@ describe("@solidjs/compiler transform", () => { expect(result.code).toContain('_$createComponent(Child, { name: "Jake" });'); }); + it.each(["dom", "ssr", "universal"])( + "carries authored coverage pragmas onto %s component children getters", + generate => { + const result = transform(fs.readFileSync(path.join(coveragePragmasDir, "code.js"), "utf8"), { + filename: "coveragePragmas.jsx", + moduleName: "r-dom", + generate + }); + + expect(coveragePragmas.pragmasByExport(result.code)).toEqual( + coveragePragmas.expectedFor(generate) + ); + } + ); + it("memoizes dynamic conditional component props by default", () => { const result = transform("const view = ;", { filename: "input.jsx", diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 619f28ccb..23fdfffa5 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -283,6 +283,8 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result { let mut transform = AstDomTransform::new( diff --git a/packages/compiler/src/dom/condition.rs b/packages/compiler/src/dom/condition.rs index b30016d16..de3535030 100644 --- a/packages/compiler/src/dom/condition.rs +++ b/packages/compiler/src/dom/condition.rs @@ -41,6 +41,10 @@ impl<'a> crate::shared::component_children::ComponentChildLower<'a> for AstDomTr } impl<'a> ModeLower<'a> for AstDomTransform<'a, '_> { + fn source(&self) -> &str { + self.source + } + fn wrap_conditionals_enabled(&self) -> bool { self.wrap_conditionals } diff --git a/packages/compiler/src/shared/component.rs b/packages/compiler/src/shared/component.rs index 719185117..823bc8473 100644 --- a/packages/compiler/src/shared/component.rs +++ b/packages/compiler/src/shared/component.rs @@ -164,7 +164,7 @@ pub(crate) fn lower_component_with_setup<'a, C: ComponentLower<'a>>( if children.needs_getter { running_props.push(crate::shared::ast::object_getter_property_with_setup( allocator, - element.span, + children.coverage_pragma_span.unwrap_or(element.span), "children", children.setup, children.value, diff --git a/packages/compiler/src/shared/component_children.rs b/packages/compiler/src/shared/component_children.rs index 6f8aa64ce..bc45b06aa 100644 --- a/packages/compiler/src/shared/component_children.rs +++ b/packages/compiler/src/shared/component_children.rs @@ -5,15 +5,20 @@ use crate::error::Result; use oxc_allocator::CloneIn; -use oxc_ast::ast::{Expression, JSXChild, JSXElement, JSXExpression, Statement}; -use oxc_span::GetSpan; +use oxc_ast::ast::{ + CommentPosition, Expression, JSXChild, JSXElement, JSXExpression, Program, Statement, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, Span}; +use std::collections::HashMap; use crate::shared::array::expression_to_array_element; use crate::shared::ast::arrow_return_expression; +use crate::shared::classify::jsx_text_is_filtered; use crate::shared::condition::{is_condition_shape, transform_condition_inline}; use crate::shared::fragment::lower_fragment; use crate::shared::mode_lower::{ModeLower, mode_ast}; -use crate::shared::utils::{decode_html_entities, trim_jsx_text}; +use crate::shared::utils::{coverage_ignore_block_comments, decode_html_entities, trim_jsx_text}; /// The extra seam component children need beyond [`ModeLower`]: element /// children keep their setup statements (template declarations + operations) @@ -29,6 +34,9 @@ pub(crate) struct ComponentChildren<'a> { pub(crate) value: Expression<'a>, pub(crate) needs_getter: bool, pub(crate) setup: std::vec::Vec>, + /// Where the authored coverage pragmas are anchored; see + /// [`anchor_coverage_pragmas`]. + pub(crate) coverage_pragma_span: Option, } enum ChildKind { @@ -58,6 +66,7 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( ) -> Result>> { let allocator = ctx.condition_allocator(); let ast = mode_ast(ctx); + let coverage_pragma_span = component_children_coverage_pragma_span(children, ctx.source()); let mut values = std::vec::Vec::new(); for child in children { match child { @@ -153,6 +162,7 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( value: child.value, needs_getter: !matches!(child.kind, ChildKind::Static), setup: child.setup, + coverage_pragma_span, }) } _ => { @@ -186,7 +196,81 @@ pub(crate) fn component_children<'a, C: ComponentChildLower<'a>>( value: ast.expression_array(span, ast.vec_from_iter(elements)), needs_getter: true, setup: std::vec::Vec::new(), + coverage_pragma_span, }) } }) } + +/// Babel's `filterChildren` hand-off: coverage pragmas in empty expression +/// containers accumulate until the next child that survives the filter, and +/// the first such child's pragmas lead the `children` getter — whatever kind +/// of child it is. +fn component_children_coverage_pragmas( + children: &[JSXChild<'_>], + source: &str, +) -> std::vec::Vec { + let mut pending = std::vec::Vec::new(); + for child in children { + match child { + JSXChild::ExpressionContainer(container) + if matches!(container.expression, JSXExpression::EmptyExpression(_)) => + { + pending.extend(coverage_ignore_block_comments(source, container.span)); + } + JSXChild::Text(text) if jsx_text_is_filtered(&text.value) => {} + _ if !pending.is_empty() => return pending, + _ => {} + } + } + std::vec::Vec::new() +} + +/// The span the `children` getter takes so codegen prints the authored +/// pragmas ahead of it: the first pragma's own start, which +/// [`anchor_coverage_pragmas`] attaches every pragma of the run to. +pub(crate) fn component_children_coverage_pragma_span( + children: &[JSXChild<'_>], + source: &str, +) -> Option { + component_children_coverage_pragmas(children, source) + .first() + .copied() +} + +/// Oxc attaches a comment to the token after it, so each `{/* … */}` pragma +/// hangs off its container's `}` and disappears with the JSX. Re-attach every +/// pragma of a run to the first pragma's start — a position no other node +/// starts at — so only the pragmas print, ahead of the getter spanned there. +pub(crate) fn anchor_coverage_pragmas(program: &mut Program<'_>, source: &str) { + struct Collector<'s> { + source: &'s str, + anchors: HashMap, + } + impl<'a> Visit<'a> for Collector<'_> { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let pragmas = component_children_coverage_pragmas(&element.children, self.source); + if let Some(anchor) = pragmas.first() { + for pragma in &pragmas { + self.anchors.insert(pragma.start, anchor.start); + } + } + walk::walk_jsx_element(self, element); + } + } + + let mut collector = Collector { + source, + anchors: HashMap::new(), + }; + collector.visit_program(program); + if collector.anchors.is_empty() { + return; + } + for comment in program.comments.iter_mut() { + if let Some(&anchor) = collector.anchors.get(&comment.span.start) { + comment.attached_to = anchor; + comment.position = CommentPosition::Leading; + } + } +} diff --git a/packages/compiler/src/shared/mode_lower.rs b/packages/compiler/src/shared/mode_lower.rs index eebbf81dd..5e93b1774 100644 --- a/packages/compiler/src/shared/mode_lower.rs +++ b/packages/compiler/src/shared/mode_lower.rs @@ -18,6 +18,9 @@ use crate::shared::condition::{ }; pub(crate) trait ModeLower<'a>: ConditionBuilder<'a> { + /// Original source, used to identify comments on JSX empty expressions. + fn source(&self) -> &str; + /// Whether `wrapConditionals` is enabled for this generate. fn wrap_conditionals_enabled(&self) -> bool; diff --git a/packages/compiler/src/shared/utils.rs b/packages/compiler/src/shared/utils.rs index 9ec982a2d..4f07bd283 100644 --- a/packages/compiler/src/shared/utils.rs +++ b/packages/compiler/src/shared/utils.rs @@ -234,6 +234,57 @@ pub(crate) fn source_from_span(span: Span, source: &str) -> &str { &source[span.start as usize..span.end as usize] } +/// Spans of the coverage-pragma block comments inside a JSX empty expression +/// container (`{/* istanbul ignore next */}`). Line comments never count, +/// matching Babel's `CommentBlock`-only filter. +pub(crate) fn coverage_ignore_block_comments(source: &str, span: Span) -> std::vec::Vec { + let mut comments = std::vec::Vec::new(); + let Some(text) = source.get(span.start as usize..span.end as usize) else { + return comments; + }; + let mut index = 0; + while index < text.len() { + let rest = &text[index..]; + if rest.starts_with("//") { + index += rest + .find(['\n', '\r', '\u{2028}', '\u{2029}']) + .unwrap_or(rest.len()); + } else if let Some(body) = rest.strip_prefix("/*") { + let Some(end) = body.find("*/") else { + break; + }; + if is_coverage_ignore_comment(&body[..end]) { + let start = span.start + index as u32; + comments.push(Span::new(start, start + end as u32 + 4)); + } + index += end + 4; + } else { + index += rest.chars().next().map_or(1, char::len_utf8); + } + } + comments +} + +/// `/^\s*(istanbul|c8)\s+ignore\b/` — `\b` is ASCII-word based, as in JS. +fn is_coverage_ignore_comment(comment: &str) -> bool { + let comment = comment.trim_start(); + ["istanbul", "c8"].iter().any(|tool| { + let Some(rest) = comment.strip_prefix(tool) else { + return false; + }; + if !rest.chars().next().is_some_and(char::is_whitespace) { + return false; + } + let Some(rest) = rest.trim_start().strip_prefix("ignore") else { + return false; + }; + !rest + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') + }) +} + /// Exact port of Babel's `trimWhitespace`: strip `\r`; for multiline text, /// drop each continuation line's indentation and all-whitespace lines, then /// join with spaces (the first line keeps its leading, and the last line its diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs index b7786f029..df26829be 100644 --- a/packages/compiler/src/ssr/transform.rs +++ b/packages/compiler/src/ssr/transform.rs @@ -19,6 +19,7 @@ use crate::shared::attr_plan::{AttrPlan, AttrPlanner, PlanValue}; use crate::shared::bindings::BindingTable; use crate::shared::classify::{Classify, jsx_text_is_filtered, significant_children}; use crate::shared::component_callee::{ComponentCalleeContext, component_callee_expression}; +use crate::shared::component_children::component_children_coverage_pragma_span; use crate::shared::component_props::{ ComponentPropContext, component_property, component_props_expression, component_spread_expression, flush_component_props, @@ -1156,7 +1157,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { } } - if let Some((children, dynamic)) = self.component_children_expression(&element.children)? { + if let Some((children, dynamic, coverage_pragma_span)) = + self.component_children_expression(&element.children)? + { if dynamic { // Babel's getter-body inlining for dynamic children: unwrap a // `memo(fn)` call to `fn.body`, a plain function to its body, @@ -1165,7 +1168,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { component_children_getter_statements(self.allocator, element.span, children); running_props.push(crate::shared::ast::object_getter_property_with_statements( self.allocator, - element.span, + coverage_pragma_span.unwrap_or(element.span), "children", statements, )); @@ -1218,7 +1221,8 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { fn component_children_expression( &mut self, children: &[JSXChild<'a>], - ) -> Result, bool)>> { + ) -> Result, bool, Option)>> { + let coverage_pragma_span = component_children_coverage_pragma_span(children, self.source); // `filterChildren`: drop empty expression containers and JSXText whose // raw starts with a newline and contains only whitespace. let filtered: std::vec::Vec<&JSXChild<'a>> = children @@ -1368,7 +1372,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { 1 => { let child = values.pop().expect("component child exists"); if child.expression_source { - Some((child.value, child.dynamic)) + Some((child.value, child.dynamic, coverage_pragma_span)) } else { // Elements/fragments force a thunk (Babel's single-child // branch in `transformComponentChildren`): a zero-arg @@ -1382,7 +1386,11 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { let mut statements = self.ast().vec(); statements.extend(setup); statements.push(self.ast().statement_return(span, Some(value))); - Some((arrow_iife(self.allocator, span, statements), true)) + Some(( + arrow_iife(self.allocator, span, statements), + true, + coverage_pragma_span, + )) } } _ => { @@ -1396,6 +1404,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> { self.ast() .expression_array(span, self.ast().vec_from_iter(elements)), true, + coverage_pragma_span, )) } }) @@ -3662,6 +3671,10 @@ impl<'a> ConditionBuilder<'a> for AstSsrTransform<'a, '_> { } impl<'a> crate::shared::mode_lower::ModeLower<'a> for AstSsrTransform<'a, '_> { + fn source(&self) -> &str { + self.source + } + fn wrap_conditionals_enabled(&self) -> bool { self.wrap_conditionals } diff --git a/packages/compiler/src/universal/transform.rs b/packages/compiler/src/universal/transform.rs index f4300100f..129857e3f 100644 --- a/packages/compiler/src/universal/transform.rs +++ b/packages/compiler/src/universal/transform.rs @@ -1834,6 +1834,10 @@ impl<'a> crate::shared::component_children::ComponentChildLower<'a> } impl<'a> crate::shared::mode_lower::ModeLower<'a> for AstUniversalTransform<'a, '_> { + fn source(&self) -> &str { + self.source + } + fn wrap_conditionals_enabled(&self) -> bool { self.wrap_conditionals }