Skip to content

Commit 988304c

Browse files
author
Merkle Bonsai
committed
refactor: simplify @scope params handling
Replace the hand-rolled tokenizer with a postcss-value-parser pass (already a dependency). Match three explicit grammar shapes that mirror the spec: (start), to (end), and (start) to (end). The `to(...)` form without whitespace, which value-parser tokenizes as a single function, is normalized into [to, (...)] upfront so all spacings collapse to one match. Also dedupe the at-rule body iteration that was identical between the @scope branch and the non-scope branch. Net: -93 LOC vs the prior @scope chunk, same end-to-end performance.
1 parent 6b9c5a5 commit 988304c

1 file changed

Lines changed: 73 additions & 163 deletions

File tree

src/index.js

Lines changed: 73 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -38,100 +38,38 @@ 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.
41+
// Parse `@scope (start)? (to (end))?` params (#90). Uses postcss-value-parser
42+
// to tokenize parens, strings, escapes, and comments correctly. Quirk: `to(...)`
43+
// with no whitespace parses as one function — we split it back so all spacings
44+
// reduce to the same three grammar shapes.
4645
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-
}
46+
const nodes = valueParser(params)
47+
.nodes.filter((n) => n.type !== "space")
48+
.flatMap((n) =>
49+
n.type === "function" && n.value.toLowerCase() === "to"
50+
? [
51+
{ type: "word", value: "to" },
52+
{ ...n, value: "" },
53+
]
54+
: [n]
55+
);
11656

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-
}
57+
const isParen = (n) => n && n.type === "function" && n.value === "";
58+
const isTo = (n) => n && n.type === "word" && n.value.toLowerCase() === "to";
59+
const inner = (n) => valueParser.stringify(n.nodes);
13360

134-
return { start, end };
61+
if (nodes.length === 1 && isParen(nodes[0]))
62+
return { start: inner(nodes[0]), end: null };
63+
if (nodes.length === 2 && isTo(nodes[0]) && isParen(nodes[1]))
64+
return { start: null, end: inner(nodes[1]) };
65+
if (
66+
nodes.length === 3 &&
67+
isParen(nodes[0]) &&
68+
isTo(nodes[1]) &&
69+
isParen(nodes[2])
70+
)
71+
return { start: inner(nodes[0]), end: inner(nodes[2]) };
72+
return null;
13573
}
13674

13775
function normalizeNodeArray(nodes) {
@@ -709,88 +647,60 @@ module.exports = (options = {}) => {
709647
global: globalKeyframes,
710648
});
711649
});
712-
} else if (/scope$/i.test(atRule.name)) {
713-
if (atRule.params) {
714-
const ignoreComment = pureMode
715-
? getIgnoreComment(atRule)
716-
: undefined;
717-
718-
if (ignoreComment) {
719-
ignoreComment.remove();
720-
}
650+
return;
651+
}
721652

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)
653+
if (/scope$/i.test(atRule.name) && atRule.params) {
654+
const ignoreComment = pureMode && getIgnoreComment(atRule);
655+
if (ignoreComment) ignoreComment.remove();
656+
657+
const parsed = parseScopeParams(atRule.params);
658+
if (!parsed) {
659+
atRule.warn(
660+
result,
661+
`Could not parse @scope params; selectors will not be localized. Params: ${JSON.stringify(
662+
atRule.params
663+
)}`
664+
);
665+
} else {
666+
const localize = (selector) => {
667+
const context = localizeNode(
668+
selector.trim(),
669+
options.mode,
670+
localAliasMap
729671
);
730-
}
731-
if (parsed) {
732-
const localizeSelector = (selector) => {
733-
const context = localizeNode(
734-
selector,
735-
options.mode,
736-
localAliasMap
672+
if (
673+
enforcePureMode &&
674+
context.hasPureGlobals &&
675+
!ignoreComment
676+
) {
677+
throw atRule.error(
678+
'Selector in at-rule"' +
679+
selector +
680+
'" is not pure ' +
681+
"(pure selectors must contain at least one local class or id)"
737682
);
738-
context.options = options;
739-
context.localAliasMap = localAliasMap;
740-
if (
741-
enforcePureMode &&
742-
context.hasPureGlobals &&
743-
!ignoreComment
744-
) {
745-
throw atRule.error(
746-
'Selector in at-rule"' +
747-
selector +
748-
'" is not pure ' +
749-
"(pure selectors must contain at least one local class or id)"
750-
);
751-
}
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})`;
770683
}
771-
}
684+
return context.selector;
685+
};
686+
atRule.params = [
687+
parsed.start !== null && `(${localize(parsed.start)})`,
688+
parsed.end !== null && `to (${localize(parsed.end)})`,
689+
]
690+
.filter(Boolean)
691+
.join(" ");
772692
}
693+
}
773694

774-
// Guard matches the non-scope branch below — body-less @scope
775-
// at-rules (or postcss-misparsed inputs) have undefined .nodes;
776-
// unconditional forEach crashes.
777-
if (atRule.nodes) {
778-
atRule.nodes.forEach((declaration) => {
779-
if (declaration.type === "decl") {
780-
localizeDeclaration(declaration, {
781-
localAliasMap,
782-
options: options,
783-
global: globalMode,
784-
});
785-
}
786-
});
787-
}
788-
} else if (atRule.nodes) {
695+
// Localize decls in the at-rule body. Shallow on purpose — nested
696+
// rules are picked up by walkRules below. Body-less at-rules
697+
// (e.g. `@scope (.foo);`) have undefined .nodes.
698+
if (atRule.nodes) {
789699
atRule.nodes.forEach((declaration) => {
790700
if (declaration.type === "decl") {
791701
localizeDeclaration(declaration, {
792702
localAliasMap,
793-
options: options,
703+
options,
794704
global: globalMode,
795705
});
796706
}

0 commit comments

Comments
 (0)