Skip to content

Commit e4f1ae6

Browse files
author
Merkle Bonsai
committed
fix: parse @scope params with paren-aware tokenizer
1 parent 106cedd commit e4f1ae6

2 files changed

Lines changed: 282 additions & 11 deletions

File tree

src/index.js

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,102 @@ function getIgnoreComment(node) {
3838
}
3939
}
4040

41+
// Parse @scope at-rule params into scope-start / scope-end clauses.
42+
// Grammar: <scope-start>? (to <scope-end>)? where each clause is "(...)".
43+
// Tracks paren depth, string literals, CSS comments, and identifier escapes
44+
// so the "to" keyword is detected at the structural position rather than
45+
// matched as a substring. Returns null on unparseable input. Fixes #90.
46+
function parseScopeParams(params) {
47+
const len = params.length;
48+
let i = 0;
49+
50+
const skipWs = () => {
51+
while (i < len) {
52+
if (/\s/.test(params[i])) {
53+
i++;
54+
} else if (params[i] === "/" && params[i + 1] === "*") {
55+
const close = params.indexOf("*/", i + 2);
56+
if (close === -1) return false;
57+
i = close + 2;
58+
} else {
59+
return true;
60+
}
61+
}
62+
return true;
63+
};
64+
65+
// Read inner contents of `(...)` at position i; advance i past `)`.
66+
// Tracks paren depth, string literals (with backslash escapes), CSS
67+
// comments, and CSS ident escapes (`\(`, `\)`, etc.).
68+
const readParens = () => {
69+
if (params[i] !== "(") return null;
70+
let depth = 1;
71+
let j = i + 1;
72+
let inStr = null;
73+
while (j < len && depth > 0) {
74+
const c = params[j];
75+
if (inStr) {
76+
if (c === "\\") {
77+
j += 2; // skip the escaped char (incl. closing quote, newline, etc.)
78+
continue;
79+
}
80+
if (c === inStr) inStr = null;
81+
j++;
82+
} else if (c === "\\") {
83+
j += 2; // CSS ident escape — the next char is literal, ignore parens
84+
} else if (c === "/" && params[j + 1] === "*") {
85+
const close = params.indexOf("*/", j + 2);
86+
if (close === -1) return null;
87+
j = close + 2;
88+
} else if (c === '"' || c === "'") {
89+
inStr = c;
90+
j++;
91+
} else if (c === "(") {
92+
depth++;
93+
j++;
94+
} else if (c === ")") {
95+
depth--;
96+
j++;
97+
} else {
98+
j++;
99+
}
100+
}
101+
if (depth !== 0) return null;
102+
const inner = params.slice(i + 1, j - 1);
103+
i = j;
104+
return inner;
105+
};
106+
107+
if (!skipWs()) return null;
108+
let start = null;
109+
let end = null;
110+
111+
if (params[i] === "(") {
112+
start = readParens();
113+
if (start === null) return null;
114+
if (!skipWs()) return null;
115+
}
116+
117+
if (i < len) {
118+
// Expect "to" keyword (case-insensitive per CSS) followed by `(scope-end)`.
119+
if (params.slice(i, i + 2).toLowerCase() !== "to") return null;
120+
// Boundary check: the char after "to" must be whitespace, "(", or
121+
// start-of-comment. Empty fallback is safe: if "to" is at end-of-input
122+
// with no trailing context, the subsequent paren check rejects it.
123+
const next = i + 2 < len ? params[i + 2] : "";
124+
if (next !== "" && !/\s|\(/.test(next) && next !== "/") return null;
125+
i += 2;
126+
if (!skipWs()) return null;
127+
if (params[i] !== "(") return null;
128+
end = readParens();
129+
if (end === null) return null;
130+
if (!skipWs()) return null;
131+
if (i < len) return null; // trailing garbage
132+
}
133+
134+
return { start, end };
135+
}
136+
41137
function normalizeNodeArray(nodes) {
42138
const array = [];
43139

@@ -561,7 +657,7 @@ module.exports = (options = {}) => {
561657
const localAliasMap = new Map();
562658

563659
return {
564-
Once(root) {
660+
Once(root, { result }) {
565661
const { icssImports } = extractICSS(root, false);
566662
const enforcePureMode = pureMode && !isPureCheckDisabled(root);
567663

@@ -623,19 +719,24 @@ module.exports = (options = {}) => {
623719
ignoreComment.remove();
624720
}
625721

626-
atRule.params = atRule.params
627-
.split("to")
628-
.map((item) => {
629-
const selector = item.trim().slice(1, -1).trim();
722+
const parsed = parseScopeParams(atRule.params);
723+
if (!parsed) {
724+
atRule.warn(
725+
result,
726+
"Could not parse @scope params; selectors will not be " +
727+
"localized for this rule. Params: " +
728+
JSON.stringify(atRule.params)
729+
);
730+
}
731+
if (parsed) {
732+
const localizeSelector = (selector) => {
630733
const context = localizeNode(
631734
selector,
632735
options.mode,
633736
localAliasMap
634737
);
635-
636738
context.options = options;
637739
context.localAliasMap = localAliasMap;
638-
639740
if (
640741
enforcePureMode &&
641742
context.hasPureGlobals &&
@@ -648,10 +749,26 @@ module.exports = (options = {}) => {
648749
"(pure selectors must contain at least one local class or id)"
649750
);
650751
}
651-
652-
return `(${context.selector})`;
653-
})
654-
.join(" to ");
752+
return context.selector;
753+
};
754+
755+
const start =
756+
parsed.start !== null
757+
? localizeSelector(parsed.start.trim())
758+
: null;
759+
const end =
760+
parsed.end !== null
761+
? localizeSelector(parsed.end.trim())
762+
: null;
763+
764+
if (start !== null && end !== null) {
765+
atRule.params = `(${start}) to (${end})`;
766+
} else if (start !== null) {
767+
atRule.params = `(${start})`;
768+
} else if (end !== null) {
769+
atRule.params = `to (${end})`;
770+
}
771+
}
655772
}
656773

657774
atRule.nodes.forEach((declaration) => {

test/index.test.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2054,6 +2054,160 @@ html {
20542054
color: red;
20552055
}
20562056
}
2057+
`,
2058+
},
2059+
// ── Regression: #90 — class names containing the substring "to" ────
2060+
// Previously `params.split("to")` truncated any class name containing
2061+
// "to" (like "button" containing "bu" + "to" + "n"), producing
2062+
// malformed output with extra `to ()` clauses and partial class names.
2063+
{
2064+
name: "@scope at-rule — class name contains 'to' substring (#90)",
2065+
input: `
2066+
@scope (.button) to (.toolbar) {
2067+
.button {
2068+
color: red;
2069+
}
2070+
}
2071+
`,
2072+
expected: `
2073+
@scope (:local(.button)) to (:local(.toolbar)) {
2074+
:local(.button) {
2075+
color: red;
2076+
}
2077+
}
2078+
`,
2079+
},
2080+
{
2081+
name: "@scope at-rule — multiple classes with 'to' substring (#90)",
2082+
input: `
2083+
@scope (.photo-tile) to (.tooltip, .stockton) {
2084+
.into-view {
2085+
color: red;
2086+
}
2087+
}
2088+
`,
2089+
expected: `
2090+
@scope (:local(.photo-tile)) to (:local(.tooltip), :local(.stockton)) {
2091+
:local(.into-view) {
2092+
color: red;
2093+
}
2094+
}
2095+
`,
2096+
},
2097+
{
2098+
name: "@scope at-rule — attribute selector value contains 'to' (#90)",
2099+
input: `
2100+
@scope ([data-section="footer"]) to ([role="button"]) {
2101+
.root {
2102+
color: red;
2103+
}
2104+
}
2105+
`,
2106+
expected: `
2107+
@scope ([data-section="footer"]) to ([role="button"]) {
2108+
:local(.root) {
2109+
color: red;
2110+
}
2111+
}
2112+
`,
2113+
},
2114+
{
2115+
name: "@scope at-rule — bare class with 'to' inside but no scope-end (#90)",
2116+
input: `
2117+
@scope (.tooltip) {
2118+
.body {
2119+
color: red;
2120+
}
2121+
}
2122+
`,
2123+
expected: `
2124+
@scope (:local(.tooltip)) {
2125+
:local(.body) {
2126+
color: red;
2127+
}
2128+
}
2129+
`,
2130+
},
2131+
// CSS comments inside `@scope` params can contain unbalanced parens,
2132+
// a literal `to`, or both. Naive paren-depth counting would miscount;
2133+
// the parser must skip `/* ... */` regions when walking selector text.
2134+
{
2135+
name: "@scope at-rule — CSS comment containing 'to' and parens (#90)",
2136+
input: `
2137+
@scope (.foo /* hi ) to ( bye */) to (.bar) {
2138+
.body {
2139+
color: red;
2140+
}
2141+
}
2142+
`,
2143+
expected: `
2144+
@scope (:local(.foo)) to (:local(.bar)) {
2145+
:local(.body) {
2146+
color: red;
2147+
}
2148+
}
2149+
`,
2150+
},
2151+
// CSS identifier escapes — `\(` and `\)` are legal in identifiers
2152+
// (e.g. CSS-in-JS tools sometimes emit them). The parser must treat
2153+
// backslash-escaped chars as literal so paren depth stays balanced.
2154+
{
2155+
name: "@scope at-rule — escaped paren in identifier (#90)",
2156+
input: `
2157+
@scope (.foo\\(bar) to (.baz) {
2158+
.body {
2159+
color: red;
2160+
}
2161+
}
2162+
`,
2163+
expected: `
2164+
@scope (:local(.foo\\(bar)) to (:local(.baz)) {
2165+
:local(.body) {
2166+
color: red;
2167+
}
2168+
}
2169+
`,
2170+
},
2171+
// The `to` keyword is case-insensitive per CSS keyword rules.
2172+
{
2173+
name: "@scope at-rule — uppercase TO keyword (#90)",
2174+
input: `
2175+
@scope (.foo) TO (.bar) {
2176+
.body {
2177+
color: red;
2178+
}
2179+
}
2180+
`,
2181+
expected: `
2182+
@scope (:local(.foo)) to (:local(.bar)) {
2183+
:local(.body) {
2184+
color: red;
2185+
}
2186+
}
2187+
`,
2188+
},
2189+
// Real-world `@scope` inputs use arbitrary functional-pseudo nesting:
2190+
// `:is()`, `:not()`, `:where()`, `:has()`, and full selector lists with
2191+
// commas. The parser separates scope-start from scope-end by matching
2192+
// the outermost paren pairs and the `to` keyword that appears between
2193+
// them at depth 0 — not by string-splitting. This case exercises that:
2194+
// both clauses contain colons, multiple parens, and the localizer must
2195+
// descend into each nested selector to localize the bare classes.
2196+
{
2197+
name: "@scope at-rule — nested :is()/:not() selectors",
2198+
input: `
2199+
@scope (:is(.class:not(.another-class))) to (:not(:is(.class):not(.another-class))) {
2200+
.root {
2201+
color: red;
2202+
}
2203+
}
2204+
`,
2205+
expected: `
2206+
@scope (:is(:local(.class):not(:local(.another-class)))) to (:not(:is(:local(.class)):not(:local(.another-class)))) {
2207+
:local(.root) {
2208+
color: red;
2209+
}
2210+
}
20572211
`,
20582212
},
20592213
];

0 commit comments

Comments
 (0)