From 937b32b22f6c2f2d37ef7c70a48b187dabe80723 Mon Sep 17 00:00:00 2001 From: mizdra Date: Sun, 14 Jun 2026 20:41:47 +0900 Subject: [PATCH] refactor(core, eslint-plugin, stylelint-plugin): replace postcss with css-tree The postcss-based parser required three companion libraries (safe-parser, selector-parser, value-parser). css-tree provides tolerant parsing, selector and value ASTs, and node positions in a single dependency, reducing the supply-chain surface. Co-Authored-By: Claude Opus 4.8 --- .changeset/css-tree-parser.md | 5 + packages/codegen/e2e-test/index.test.ts | 4 +- packages/codegen/src/project.test.ts | 6 +- packages/core/package.json | 8 +- packages/core/src/index.ts | 4 +- packages/core/src/parser/animation-parser.ts | 53 +-- .../core/src/parser/at-import-parser.test.ts | 19 +- packages/core/src/parser/at-import-parser.ts | 42 ++- .../core/src/parser/at-value-parser.test.ts | 11 +- packages/core/src/parser/at-value-parser.ts | 203 ++++++---- packages/core/src/parser/composes-parser.ts | 72 ++-- .../core/src/parser/css-module-parser.test.ts | 349 +++++++++--------- packages/core/src/parser/css-module-parser.ts | 205 +++++----- packages/core/src/parser/csstree.ts | 108 ++++++ .../core/src/parser/decl-value-location.ts | 16 - .../core/src/parser/key-frame-parser.test.ts | 1 - packages/core/src/parser/key-frame-parser.ts | 63 ++-- packages/core/src/parser/rule-parser.test.ts | 239 ++---------- packages/core/src/parser/rule-parser.ts | 202 ++++------ packages/core/src/test/ast.ts | 67 ++-- packages/core/src/util.test.ts | 20 +- packages/core/src/util.ts | 31 +- packages/eslint-plugin/package.json | 3 +- .../src/rules/no-unused-class-names.ts | 55 +-- .../src/rules/no-unused-class-names.ts | 42 ++- pnpm-lock.yaml | 41 +- 26 files changed, 874 insertions(+), 995 deletions(-) create mode 100644 .changeset/css-tree-parser.md create mode 100644 packages/core/src/parser/csstree.ts delete mode 100644 packages/core/src/parser/decl-value-location.ts diff --git a/.changeset/css-tree-parser.md b/.changeset/css-tree-parser.md new file mode 100644 index 00000000..0d223e0b --- /dev/null +++ b/.changeset/css-tree-parser.md @@ -0,0 +1,5 @@ +--- +'@css-modules-kit/core': patch +--- + +Replace the postcss-based CSS parser with css-tree to reduce dependencies diff --git a/packages/codegen/e2e-test/index.test.ts b/packages/codegen/e2e-test/index.test.ts index 630f321c..39a83d1f 100644 --- a/packages/codegen/e2e-test/index.test.ts +++ b/packages/codegen/e2e-test/index.test.ts @@ -130,10 +130,10 @@ test('reports CSS syntax error', async () => { const cmk = spawnSync('node', [binPath, '--pretty'], { cwd: iff.rootDir }); expect(cmk.status).toBe(1); expect(stripVTControlCharacters(cmk.stderr.toString())).toMatchInlineSnapshot(` - "src/a.module.css:1:1 - error: Unknown word badword + "src/a.module.css:1:8 - error: "{" is expected 1 badword - ~~~~~~~ + " `); diff --git a/packages/codegen/src/project.test.ts b/packages/codegen/src/project.test.ts index 9cd20a68..ef9eaed9 100644 --- a/packages/codegen/src/project.test.ts +++ b/packages/codegen/src/project.test.ts @@ -436,12 +436,12 @@ describe('getDiagnostics', () => { { "category": "error", "fileName": "/src/b.module.css", - "length": 5, + "length": 1, "start": { - "column": 8, + "column": 14, "line": 1, }, - "text": "Unknown word color", + "text": "Colon is expected", }, ] `); diff --git a/packages/core/package.json b/packages/core/package.json index 9826c3bb..92ac72ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,10 +40,10 @@ "build": "tsc -b tsconfig.build.json" }, "dependencies": { - "postcss": "^8.5.9", - "postcss-safe-parser": "^7.0.1", - "postcss-selector-parser": "^7.1.1", - "postcss-value-parser": "^4.2.0" + "css-tree": "^3.2.1" + }, + "devDependencies": { + "@types/css-tree": "^2.3.11" }, "peerDependencies": { "typescript": ">=5.7.3" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 44add0cb..dd2ecf5f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,8 @@ export type { CMKConfig } from './config.js'; export { readConfigFile } from './config.js'; export { TsConfigFileNotFoundError, SystemError } from './error.js'; -export { parseCSSModule, type ParseCSSModuleOptions } from './parser/css-module-parser.js'; -export { parseRule } from './parser/rule-parser.js'; +export { parseCSSModule, type ParseCSSModuleOptions, getClassSelectors } from './parser/css-module-parser.js'; +export type { ClassSelector } from './parser/rule-parser.js'; export { type Location, type Position, diff --git a/packages/core/src/parser/animation-parser.ts b/packages/core/src/parser/animation-parser.ts index 297d07d8..e125238d 100644 --- a/packages/core/src/parser/animation-parser.ts +++ b/packages/core/src/parser/animation-parser.ts @@ -1,7 +1,6 @@ -import type { Declaration } from 'postcss'; -import postcssValueParser from 'postcss-value-parser'; +import type { Declaration, FunctionNode, Identifier } from 'css-tree'; import type { DiagnosticWithDetachedLocation, TokenReference } from '../type.js'; -import { calcDeclValueLoc } from './decl-value-location.js'; +import { toLocation } from './csstree.js'; const ANIMATION_NAME_PROP_RE = /^(?:-(?:webkit|moz|o|ms)-)?animation-name$/iu; const ANIMATION_PROP_RE = /^(?:-(?:webkit|moz|o|ms)-)?animation$/iu; @@ -83,35 +82,27 @@ export function parseAnimationProp(decl: Declaration): ParseAnimationResult { /** * Walk the top-level value nodes and collect token references. `local(...)` is unwrapped * to a single reference, while `global(...)`, `var()`, and other functions are skipped. - * Word nodes are turned into references only when `isReference` returns `true`. + * Identifiers are turned into references only when `isReference` returns `true`. */ function collectAnimationReferences(decl: Declaration, isReference: (word: string) => boolean): ParseAnimationResult { const references: TokenReference[] = []; const diagnostics: DiagnosticWithDetachedLocation[] = []; - const parsed = postcssValueParser(decl.value); - for (const node of parsed.nodes) { - if (node.type === 'function') { + if (decl.value.type !== 'Value') return { references, diagnostics }; + for (const node of decl.value.children) { + if (node.type === 'Function') { // `global(name)`, `var(...)`, `env(...)`, and any other function are skipped. - if (node.value !== 'local') continue; + if (node.name !== 'local') continue; const nameNodeOrError = unwrapLocalCall(node); if (nameNodeOrError === 'invalid') { - diagnostics.push(createInvalidLocalCallDiagnostic(decl, node)); + diagnostics.push(createInvalidLocalCallDiagnostic(node)); continue; } - references.push({ - type: 'local', - name: nameNodeOrError.value, - loc: calcDeclValueLoc(decl, nameNodeOrError.sourceIndex, nameNodeOrError.value.length), - }); + references.push({ type: 'local', name: nameNodeOrError.name, loc: toLocation(nameNodeOrError.loc!) }); continue; } - if (node.type !== 'word') continue; - if (!isReference(node.value)) continue; - references.push({ - type: 'local', - name: node.value, - loc: calcDeclValueLoc(decl, node.sourceIndex, node.value.length), - }); + if (node.type !== 'Identifier') continue; + if (!isReference(node.name)) continue; + references.push({ type: 'local', name: node.name, loc: toLocation(node.loc!) }); } return { references, diagnostics }; } @@ -131,24 +122,20 @@ function isKeyframesName(word: string): boolean { * Inspect a `local(...)` call and return its single identifier, or `'invalid'` * for any other shape (empty, multiple words, nested functions, strings, etc.). */ -function unwrapLocalCall(fn: postcssValueParser.FunctionNode): postcssValueParser.WordNode | 'invalid' { - const words = fn.nodes.filter((n) => n.type !== 'space'); - if (words.length === 1 && words[0]!.type === 'word') { - return words[0]!; +function unwrapLocalCall(fn: FunctionNode): Identifier | 'invalid' { + const nodes = fn.children.toArray(); + if (nodes.length === 1 && nodes[0]!.type === 'Identifier') { + return nodes[0]; } return 'invalid'; } -function createInvalidLocalCallDiagnostic( - decl: Declaration, - fn: postcssValueParser.FunctionNode, -): DiagnosticWithDetachedLocation { - const length = fn.sourceEndIndex - fn.sourceIndex; - const { start } = calcDeclValueLoc(decl, fn.sourceIndex, length); +function createInvalidLocalCallDiagnostic(fn: FunctionNode): DiagnosticWithDetachedLocation { + const loc = fn.loc!; return { text: '`local(...)` must contain exactly one identifier.', category: 'error', - start: { line: start.line, column: start.column }, - length, + start: { line: loc.start.line, column: loc.start.column }, + length: loc.end.offset - loc.start.offset, }; } diff --git a/packages/core/src/parser/at-import-parser.test.ts b/packages/core/src/parser/at-import-parser.test.ts index a48140e8..ddcb3f86 100644 --- a/packages/core/src/parser/at-import-parser.test.ts +++ b/packages/core/src/parser/at-import-parser.test.ts @@ -4,16 +4,15 @@ import { fakeAtImports, fakeRoot } from '../test/ast.js'; import { parseAtImport } from './at-import-parser.js'; test('parseAtImport', () => { - const atImports = fakeAtImports( - fakeRoot(dedent` - @import; - @import "test.css"; - @import url("test.css"); - @import url(test.css); - @import "test.css" print; - `), - ); - expect(atImports.map(parseAtImport)).toMatchInlineSnapshot(` + const text = dedent` + @import; + @import "test.css"; + @import url("test.css"); + @import url(test.css); + @import "test.css" print; + `; + const atImports = fakeAtImports(fakeRoot(text)); + expect(atImports.map((atImport) => parseAtImport(atImport, text))).toMatchInlineSnapshot(` [ undefined, { diff --git a/packages/core/src/parser/at-import-parser.ts b/packages/core/src/parser/at-import-parser.ts index 57e1652b..4a906fae 100644 --- a/packages/core/src/parser/at-import-parser.ts +++ b/packages/core/src/parser/at-import-parser.ts @@ -1,6 +1,6 @@ -import type { AtRule } from 'postcss'; -import postcssValueParser from 'postcss-value-parser'; +import type { Atrule, Url } from 'css-tree'; import type { Location } from '../type.js'; +import { stringInnerLocation } from './csstree.js'; interface ParsedAtImport { from: string; @@ -10,33 +10,35 @@ interface ParsedAtImport { /** * Parse the `@import` rule. * @param atImport The `@import` rule to parse. + * @param text The source text of the CSS Module, used to locate the specifier inside `url(...)`. * @returns The specifier of the imported file. */ -export function parseAtImport(atImport: AtRule): ParsedAtImport | undefined { - const firstNode = postcssValueParser(atImport.params).nodes[0]; - if (firstNode === undefined) return undefined; - if (firstNode.type === 'string') return convertParsedAtImport(atImport, firstNode); - if (firstNode.type === 'function' && firstNode.value === 'url') { - if (firstNode.nodes[0] === undefined) return undefined; - if (firstNode.nodes[0].type === 'string') return convertParsedAtImport(atImport, firstNode.nodes[0]); - if (firstNode.nodes[0].type === 'word') return convertParsedAtImport(atImport, firstNode.nodes[0]); +export function parseAtImport(atImport: Atrule, text: string): ParsedAtImport | undefined { + const prelude = atImport.prelude; + if (prelude === null || prelude.type !== 'AtrulePrelude') return undefined; + const firstNode = prelude.children.first; + if (firstNode === null) return undefined; + if (firstNode.type === 'String') { + return { from: firstNode.value, fromLoc: stringInnerLocation(firstNode.loc!) }; + } + if (firstNode.type === 'Url') { + return parseUrl(firstNode, text); } return undefined; } -function convertParsedAtImport( - atImport: AtRule, - node: postcssValueParser.StringNode | postcssValueParser.WordNode, -): ParsedAtImport { - // The length of the `@import ` part in `@import '...'` - const baseLength = 7 + (atImport.raws.afterName?.length ?? 0); - // For string nodes, skip the leading quote to point at the specifier itself. - const startIndex = baseLength + node.sourceIndex + (node.type === 'string' ? 1 : 0); +function parseUrl(node: Url, text: string): ParsedAtImport { + const loc = node.loc!; + // `url(` and the specifier are always on the same line, so a column offset is sufficient. + let offset = loc.start.offset + 'url('.length; + while (/\s/u.test(text[offset] ?? '')) offset++; + if (text[offset] === '"' || text[offset] === "'") offset++; + const startColumn = loc.start.column + (offset - loc.start.offset); return { from: node.value, fromLoc: { - start: atImport.positionBy({ index: startIndex }), - end: atImport.positionBy({ index: startIndex + node.value.length }), + start: { line: loc.start.line, column: startColumn, offset }, + end: { line: loc.start.line, column: startColumn + node.value.length, offset: offset + node.value.length }, }, }; } diff --git a/packages/core/src/parser/at-value-parser.test.ts b/packages/core/src/parser/at-value-parser.test.ts index 6a2a3464..0d328104 100644 --- a/packages/core/src/parser/at-value-parser.test.ts +++ b/packages/core/src/parser/at-value-parser.test.ts @@ -416,9 +416,8 @@ describe('parseAtValue', () => { // guarantee any specific behavior for them. The snapshots below document how the current // implementation parses them rather than a behavior we commit to. // - // The `@value \31 e` case is tokenized as `\31` and `e` instead of a single ident `1e`, - // because postcss-value-parser does not interpret CSS escape sequences in identifiers. - // This is a known bug: https://github.com/postcss/postcss-value-parser/issues/64 + // The `@value \31 e` case is parsed as the single ident `\31 e`, because css-tree's tokenizer + // treats the CSS escape sequence and the following characters as one identifier. const atValues = fakeAtValues( fakeRoot(dedent` @value; @@ -583,9 +582,9 @@ describe('parseAtValue', () => { }, "loc": { "end": { - "column": 11, + "column": 13, "line": 5, - "offset": 83, + "offset": 85, }, "start": { "column": 8, @@ -593,7 +592,7 @@ describe('parseAtValue', () => { "offset": 80, }, }, - "name": "\\31", + "name": "\\31 e", "type": "declaration", }, "diagnostics": [], diff --git a/packages/core/src/parser/at-value-parser.ts b/packages/core/src/parser/at-value-parser.ts index 8e073bca..df4ebc75 100644 --- a/packages/core/src/parser/at-value-parser.ts +++ b/packages/core/src/parser/at-value-parser.ts @@ -1,6 +1,12 @@ -import type { AtRule } from 'postcss'; -import postcssValueParser from 'postcss-value-parser'; -import type { DiagnosticWithDetachedLocation, Location } from '../type.js'; +import type { Atrule, Raw } from 'css-tree'; +import type { DiagnosticWithDetachedLocation, Location, Position } from '../type.js'; +import { offsetToPosition, tokenize, tokenTypes } from './csstree.js'; + +const IDENT = tokenTypes['Ident']!; +const STRING = tokenTypes['String']!; +const COMMA = tokenTypes['Comma']!; +const WHITESPACE = tokenTypes['WhiteSpace']!; +const COMMENT = tokenTypes['Comment']!; interface ValueDeclaration { type: 'declaration'; @@ -32,49 +38,83 @@ interface ParseAtValueResult { diagnostics: DiagnosticWithDetachedLocation[]; } +/** A token of the `@value` prelude with its absolute location. */ +interface PreludeToken { + type: number; + value: string; + start: Position; + end: Position; +} + /** * Parse the `@value` rule. * * MEMO: css-modules-kit does not support `@value` with parentheses (e.g., `@value (a, b) from '...';`) to simplify the implementation. * MEMO: css-modules-kit does not support `@value` with variable module name (e.g., `@value a from moduleName;`) to simplify the implementation. */ -export function parseAtValue(atValue: AtRule): ParseAtValueResult { - const nodes = postcssValueParser(atValue.params).nodes; - if (isValueImporter(nodes)) { - return parseValueImporter(atValue, nodes); +export function parseAtValue(atValue: Atrule): ParseAtValueResult { + const raw = getRawPrelude(atValue); + if (raw === undefined) return invalidDeclaration(atValue, undefined); + const tokens = tokenizePrelude(raw); + if (isValueImporter(tokens)) { + return parseValueImporter(raw, tokens); } - return parseValueDeclaration(atValue, nodes); + return parseValueDeclaration(atValue, raw, tokens); } -/** - * Check that the params form a value importer: one or more nodes followed by `from` - * and a single quoted specifier (e.g. `'./test.module.css'`). - */ -function isValueImporter(nodes: postcssValueParser.Node[]): boolean { - const fromIndex = findFromKeywordIndex(nodes); +function getRawPrelude(atValue: Atrule): Raw | undefined { + const prelude = atValue.prelude; + if (prelude === null || prelude.type !== 'AtrulePrelude') return undefined; + const first = prelude.children.first; + return first !== null && first.type === 'Raw' ? first : undefined; +} + +/** Tokenize the unparsed `@value` prelude, dropping whitespace and comments. */ +function tokenizePrelude(raw: Raw): PreludeToken[] { + const source = raw.value; + const base = raw.loc!.start; + const tokens: PreludeToken[] = []; + tokenize(source, (type, start, end) => { + if (type === WHITESPACE || type === COMMENT) return; + tokens.push({ + type, + value: source.slice(start, end), + start: offsetToPosition(source, base, start), + end: offsetToPosition(source, base, end), + }); + }); + return tokens; +} + +/** A value importer is one or more entries followed by `from` and a single quoted specifier. */ +function isValueImporter(tokens: PreludeToken[]): boolean { + const fromIndex = findFromKeywordIndex(tokens); if (fromIndex < 1) return false; - const tail = nodes.slice(fromIndex + 1).filter((node) => node.type !== 'space'); - return tail.length === 1 && tail[0]!.type === 'string'; + const tail = tokens.slice(fromIndex + 1); + return tail.length === 1 && tail[0]!.type === STRING; } -function findFromKeywordIndex(nodes: postcssValueParser.Node[]): number { - return nodes.findLastIndex((node) => node.type === 'word' && node.value === 'from'); +function findFromKeywordIndex(tokens: PreludeToken[]): number { + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]!; + if (token.type === IDENT && token.value === 'from') return i; + } + return -1; } -function parseValueImporter(atValue: AtRule, nodes: postcssValueParser.Node[]): ParseAtValueResult { - const fromIndex = findFromKeywordIndex(nodes); - const specifierNode = nodes.slice(fromIndex + 1).find((node) => node.type === 'string')!; +function parseValueImporter(raw: Raw, tokens: PreludeToken[]): ParseAtValueResult { + const fromIndex = findFromKeywordIndex(tokens); + const specifierToken = tokens[fromIndex + 1]!; const entries: ValueImporter['entries'] = []; const diagnostics: DiagnosticWithDetachedLocation[] = []; - for (const item of splitByComma(nodes.slice(0, fromIndex))) { - const words = item.nodes.filter((node) => node.type === 'word'); - const nameNode = words[0]; + for (const item of splitByComma(tokens.slice(0, fromIndex), raw.loc!.start)) { + const words = item.tokens.filter((token) => token.type === IDENT); + const nameToken = words[0]; // An empty item (e.g. the middle item in `a,,b`) has no name, so report it like `@value;`. - if (nameNode === undefined) { - const { start } = calcAtValueParamsLoc(atValue, item.sourceIndex, 0); + if (nameToken === undefined) { diagnostics.push({ - start: { line: start.line, column: start.column }, + start: { line: item.position.line, column: item.position.column }, length: 0, text: '`` is invalid syntax.', category: 'error', @@ -82,13 +122,13 @@ function parseValueImporter(atValue: AtRule, nodes: postcssValueParser.Node[]): continue; } const entry: ValueImporter['entries'][number] = { - name: nameNode.value, - loc: calcAtValueParamsLoc(atValue, nameNode.sourceIndex, nameNode.value.length), + name: nameToken.value, + loc: { start: nameToken.start, end: nameToken.end }, }; - const localNode = words[1]?.value === 'as' ? words[2] : undefined; - if (localNode !== undefined) { - entry.localName = localNode.value; - entry.localLoc = calcAtValueParamsLoc(atValue, localNode.sourceIndex, localNode.value.length); + const localToken = words[1]?.value === 'as' ? words[2] : undefined; + if (localToken !== undefined) { + entry.localName = localToken.value; + entry.localLoc = { start: localToken.start, end: localToken.end }; } entries.push(entry); } @@ -96,71 +136,72 @@ function parseValueImporter(atValue: AtRule, nodes: postcssValueParser.Node[]): const parsedAtValue: ValueImporter = { type: 'importer', entries, - from: specifierNode.value, - // The location of the specifier without quotes. - fromLoc: calcAtValueParamsLoc(atValue, specifierNode.sourceIndex + 1, specifierNode.value.length), + from: unquote(specifierToken.value), + fromLoc: innerStringLoc(specifierToken), }; return { atValue: parsedAtValue, diagnostics }; } -function parseValueDeclaration(atValue: AtRule, nodes: postcssValueParser.Node[]): ParseAtValueResult { - const nameNode = nodes.find((node) => node.type !== 'space'); - if (nameNode === undefined || nameNode.type !== 'word') { - return { - diagnostics: [ - { - start: { - line: atValue.source!.start!.line, - column: atValue.source!.start!.column, - }, - length: atValue.source!.end!.offset - atValue.source!.start!.offset, - text: `\`${atValue.toString()}\` is a invalid syntax.`, - category: 'error', - }, - ], - }; +function parseValueDeclaration(atValue: Atrule, raw: Raw, tokens: PreludeToken[]): ParseAtValueResult { + const nameToken = tokens[0]; + if (nameToken === undefined || nameToken.type !== IDENT) { + return invalidDeclaration(atValue, raw); } const parsedAtValue: ValueDeclaration = { type: 'declaration', - name: nameNode.value, - loc: calcAtValueParamsLoc(atValue, nameNode.sourceIndex, nameNode.value.length), - declarationLoc: { - start: atValue.source!.start!, - end: atValue.positionBy({ index: atValue.toString().length }), - }, + name: nameToken.value, + loc: { start: nameToken.start, end: nameToken.end }, + declarationLoc: { start: toPosition(atValue.loc!.start), end: toPosition(raw.loc!.end) }, }; return { atValue: parsedAtValue, diagnostics: [] }; } -/** - * Calculate the location of a range in the params of an at-rule. - * @param sourceIndex The index of the range in the params. - * @param length The length of the range. - */ -function calcAtValueParamsLoc(atValue: AtRule, sourceIndex: number, length: number): Location { - const baseLength = 1 + atValue.name.length + (atValue.raws.afterName?.length ?? 0); - const startIndex = baseLength + sourceIndex; +function invalidDeclaration(atValue: Atrule, raw: Raw | undefined): ParseAtValueResult { + const loc = atValue.loc!; + const text = raw === undefined ? `@${atValue.name}` : `@${atValue.name} ${raw.value}`; return { - start: atValue.positionBy({ index: startIndex }), - end: atValue.positionBy({ index: startIndex + length }), + diagnostics: [ + { + start: { line: loc.start.line, column: loc.start.column }, + length: loc.end.offset - loc.start.offset, + text: `\`${text}\` is a invalid syntax.`, + category: 'error', + }, + ], }; } interface ImportItem { - nodes: postcssValueParser.Node[]; - /** The source index where the item begins, used to locate an empty item. */ - sourceIndex: number; -} - -/** Split the nodes by top-level commas. Space nodes are dropped. */ -function splitByComma(nodes: postcssValueParser.Node[]): ImportItem[] { - const items: ImportItem[] = [{ nodes: [], sourceIndex: 0 }]; - for (const node of nodes) { - if (node.type === 'div' && node.value === ',') { - items.push({ nodes: [], sourceIndex: node.sourceEndIndex }); - } else if (node.type !== 'space') { - items.at(-1)!.nodes.push(node); + tokens: PreludeToken[]; + /** The position where the item begins, used to locate an empty item. */ + position: Position; +} + +/** Split the tokens by top-level commas. */ +function splitByComma(tokens: PreludeToken[], start: Position): ImportItem[] { + const items: ImportItem[] = [{ tokens: [], position: start }]; + for (const token of tokens) { + if (token.type === COMMA) { + items.push({ tokens: [], position: token.end }); + } else { + items.at(-1)!.tokens.push(token); } } return items; } + +function unquote(value: string): string { + return value.slice(1, -1); +} + +/** Location of a quoted specifier token's content, excluding the surrounding quotes. */ +function innerStringLoc(token: PreludeToken): Location { + return { + start: { line: token.start.line, column: token.start.column + 1, offset: token.start.offset + 1 }, + end: { line: token.end.line, column: token.end.column - 1, offset: token.end.offset - 1 }, + }; +} + +function toPosition(p: { line: number; column: number; offset: number }): Position { + return { line: p.line, column: p.column, offset: p.offset }; +} diff --git a/packages/core/src/parser/composes-parser.ts b/packages/core/src/parser/composes-parser.ts index 7a47e6ba..8e45145e 100644 --- a/packages/core/src/parser/composes-parser.ts +++ b/packages/core/src/parser/composes-parser.ts @@ -1,12 +1,11 @@ -import type { Declaration } from 'postcss'; -import postcssValueParser from 'postcss-value-parser'; +import type { CssNode, Declaration, StringNode } from 'css-tree'; import type { ExternalTokenReference, ExternalTokenReferenceEntry, LocalTokenReference, TokenReference, } from '../type.js'; -import { calcDeclValueLoc } from './decl-value-location.js'; +import { stringInnerLocation, toLocation } from './csstree.js'; const COMPOSES_PROP_RE = /^composes$/iu; @@ -16,88 +15,75 @@ export function isComposesProp(prop: string): boolean { /** Parse a `composes` declaration and extract token references. */ export function parseComposesProp(decl: Declaration): TokenReference[] { + if (decl.value.type !== 'Value') return []; const references: TokenReference[] = []; - for (const item of splitByComma(postcssValueParser(decl.value).nodes)) { - if (hasFromClause(item)) { + for (const item of splitByComma(decl.value.children.toArray())) { + const fromIndex = findFromKeywordIndex(item); + if (fromIndex >= 1 && isValidFromClauseTail(item.slice(fromIndex + 1))) { const specifierNode = item.at(-1)!; // Items with `from global` do not produce token references. - if (specifierNode.type !== 'string') continue; - const head = item.slice(0, findFromKeywordIndex(item)); - const externalReference = createExternalReference(decl, head, specifierNode); + if (specifierNode.type !== 'String') continue; + const externalReference = createExternalReference(item.slice(0, fromIndex), specifierNode); if (externalReference.entries.length > 0) references.push(externalReference); continue; } // Items without a `from` clause consist of plain class names. - references.push(...createLocalReferences(decl, item)); + references.push(...createLocalReferences(item)); } return references; } -/** - * Check that the item forms a `from` clause: one or more nodes followed by `from` - * and a quoted specifier (e.g. `'./a.module.css'`) or the keyword `global`. - */ -function hasFromClause(item: postcssValueParser.Node[]): boolean { - const fromIndex = findFromKeywordIndex(item); - return fromIndex >= 1 && isValidFromClauseTail(item.slice(fromIndex + 1)); -} - -function findFromKeywordIndex(item: postcssValueParser.Node[]): number { - return item.findLastIndex((node) => node.type === 'word' && node.value === 'from'); +function findFromKeywordIndex(item: CssNode[]): number { + for (let i = item.length - 1; i >= 0; i--) { + const node = item[i]!; + if (node.type === 'Identifier' && node.name === 'from') return i; + } + return -1; } /** * Check that the nodes after `from` represent a valid import source: a single * quoted specifier (e.g. `'./a.module.css'`) or the keyword `global`. */ -function isValidFromClauseTail(tail: postcssValueParser.Node[]): boolean { +function isValidFromClauseTail(tail: CssNode[]): boolean { if (tail.length !== 1) return false; const node = tail[0]!; - return node.type === 'string' || (node.type === 'word' && node.value === 'global'); + return node.type === 'String' || (node.type === 'Identifier' && node.name === 'global'); } /** Create a local token reference for each class name in `nodes`. */ -function createLocalReferences(decl: Declaration, nodes: postcssValueParser.Node[]): LocalTokenReference[] { +function createLocalReferences(nodes: CssNode[]): LocalTokenReference[] { const references: LocalTokenReference[] = []; for (const node of nodes) { // `global(name)` and any other function are skipped. - if (node.type !== 'word') continue; - references.push({ - type: 'local', - name: node.value, - loc: calcDeclValueLoc(decl, node.sourceIndex, node.value.length), - }); + if (node.type !== 'Identifier') continue; + references.push({ type: 'local', name: node.name, loc: toLocation(node.loc!) }); } return references; } /** Create an external token reference with an entry for each class name in `nodes`. */ -function createExternalReference( - decl: Declaration, - nodes: postcssValueParser.Node[], - specifierNode: postcssValueParser.StringNode, -): ExternalTokenReference { +function createExternalReference(nodes: CssNode[], specifierNode: StringNode): ExternalTokenReference { const entries: ExternalTokenReferenceEntry[] = []; for (const node of nodes) { - if (node.type !== 'word') continue; - entries.push({ name: node.value, loc: calcDeclValueLoc(decl, node.sourceIndex, node.value.length) }); + if (node.type !== 'Identifier') continue; + entries.push({ name: node.name, loc: toLocation(node.loc!) }); } return { type: 'external', entries, from: specifierNode.value, - // The location of the specifier without quotes. - fromLoc: calcDeclValueLoc(decl, specifierNode.sourceIndex + 1, specifierNode.value.length), + fromLoc: stringInnerLocation(specifierNode.loc!), }; } -/** Split the value nodes by top-level commas. Space nodes are dropped. */ -function splitByComma(nodes: postcssValueParser.Node[]): postcssValueParser.Node[][] { - const items: postcssValueParser.Node[][] = [[]]; +/** Split the value nodes by top-level commas. */ +function splitByComma(nodes: CssNode[]): CssNode[][] { + const items: CssNode[][] = [[]]; for (const node of nodes) { - if (node.type === 'div' && node.value === ',') { + if (node.type === 'Operator' && node.value === ',') { items.push([]); - } else if (node.type !== 'space') { + } else { items.at(-1)!.push(node); } } diff --git a/packages/core/src/parser/css-module-parser.test.ts b/packages/core/src/parser/css-module-parser.test.ts index 56b0c243..9b82df5a 100644 --- a/packages/core/src/parser/css-module-parser.test.ts +++ b/packages/core/src/parser/css-module-parser.test.ts @@ -745,73 +745,72 @@ describe('parseCSSModule', () => { options, ); expect(parsed).toMatchInlineSnapshot(` - { - "diagnostics": [ - { - "category": "error", - "file": { - "fileName": "/test.module.css", - "text": ":local .local1 {} - @value;", - }, - "length": 6, - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - "text": "css-modules-kit does not support \`:local\`. Use \`:local(...)\` instead.", - }, - { - "category": "error", - "file": { - "fileName": "/test.module.css", - "text": ":local .local1 {} - @value;", - }, - "length": 7, - "start": { - "column": 1, - "line": 2, - }, - "text": "\`@value\` is a invalid syntax.", - }, - ], - "fileName": "/test.module.css", - "localTokens": [ - { - "declarationLoc": { - "end": { - "column": 18, - "line": 1, - "offset": 17, - }, - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - }, - "loc": { - "end": { - "column": 15, - "line": 1, - "offset": 14, - }, - "start": { - "column": 9, - "line": 1, - "offset": 8, - }, - }, - "name": "local1", - }, - ], - "text": ":local .local1 {} - @value;", - "tokenImporters": [], - "tokenReferences": [], - } + { + "diagnostics": [ + { + "category": "error", + "file": { + "fileName": "/test.module.css", + "text": ":local .local1 {} + @value;", + }, + "length": 6, + "start": { + "column": 1, + "line": 1, + }, + "text": "css-modules-kit does not support \`:local\`. Use \`:local(...)\` instead.", + }, + { + "category": "error", + "file": { + "fileName": "/test.module.css", + "text": ":local .local1 {} + @value;", + }, + "length": 7, + "start": { + "column": 1, + "line": 2, + }, + "text": "\`@value\` is a invalid syntax.", + }, + ], + "fileName": "/test.module.css", + "localTokens": [ + { + "declarationLoc": { + "end": { + "column": 18, + "line": 1, + "offset": 17, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "loc": { + "end": { + "column": 15, + "line": 1, + "offset": 14, + }, + "start": { + "column": 9, + "line": 1, + "offset": 8, + }, + }, + "name": "local1", + }, + ], + "text": ":local .local1 {} + @value;", + "tokenImporters": [], + "tokenReferences": [], + } `); }); // TODO: Support local tokens by CSS variables. This is supported by lightningcss. @@ -835,80 +834,80 @@ describe('parseCSSModule', () => { options, ), ).toMatchInlineSnapshot(` - { - "diagnostics": [ - { - "category": "error", - "file": { - "fileName": "/test.module.css", - "text": ".a {", - }, - "length": 1, - "start": { - "column": 1, - "line": 1, - }, - "text": "Unclosed block", - }, - ], - "fileName": "/test.module.css", - "localTokens": [ - { - "declarationLoc": { - "end": { - "column": 6, - "line": 1, - "offset": 5, - }, - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - }, - "loc": { - "end": { - "column": 3, - "line": 1, - "offset": 2, - }, - "start": { - "column": 2, - "line": 1, - "offset": 1, - }, - }, - "name": "a", - }, - ], - "text": ".a {", - "tokenImporters": [], - "tokenReferences": [], - } + { + "diagnostics": [ + { + "category": "error", + "file": { + "fileName": "/test.module.css", + "text": ".a {", + }, + "length": 1, + "start": { + "column": 1, + "line": 1, + }, + "text": "Unclosed block", + }, + ], + "fileName": "/test.module.css", + "localTokens": [ + { + "declarationLoc": { + "end": { + "column": 5, + "line": 1, + "offset": 4, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "loc": { + "end": { + "column": 3, + "line": 1, + "offset": 2, + }, + "start": { + "column": 2, + "line": 1, + "offset": 1, + }, + }, + "name": "a", + }, + ], + "text": ".a {", + "tokenImporters": [], + "tokenReferences": [], + } `); expect(parseCSSModule('badword', options)).toMatchInlineSnapshot(` - { - "diagnostics": [ - { - "category": "error", - "file": { - "fileName": "/test.module.css", - "text": "badword", - }, - "length": 7, - "start": { - "column": 1, - "line": 1, - }, - "text": "Unknown word badword", - }, - ], - "fileName": "/test.module.css", - "localTokens": [], - "text": "badword", - "tokenImporters": [], - "tokenReferences": [], - } + { + "diagnostics": [ + { + "category": "error", + "file": { + "fileName": "/test.module.css", + "text": "badword", + }, + "length": 1, + "start": { + "column": 8, + "line": 1, + }, + "text": ""{" is expected", + }, + ], + "fileName": "/test.module.css", + "localTokens": [], + "text": "badword", + "tokenImporters": [], + "tokenReferences": [], + } `); }); test('does not include syntax error in diagnostics if includeSyntaxError is false', () => { @@ -920,42 +919,42 @@ describe('parseCSSModule', () => { { ...options, includeSyntaxError: false }, ), ).toMatchInlineSnapshot(` - { - "diagnostics": [], - "fileName": "/test.module.css", - "localTokens": [ - { - "declarationLoc": { - "end": { - "column": 6, - "line": 1, - "offset": 5, - }, - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - }, - "loc": { - "end": { - "column": 3, - "line": 1, - "offset": 2, - }, - "start": { - "column": 2, - "line": 1, - "offset": 1, - }, - }, - "name": "a", - }, - ], - "text": ".a {", - "tokenImporters": [], - "tokenReferences": [], - } + { + "diagnostics": [], + "fileName": "/test.module.css", + "localTokens": [ + { + "declarationLoc": { + "end": { + "column": 5, + "line": 1, + "offset": 4, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "loc": { + "end": { + "column": 3, + "line": 1, + "offset": 2, + }, + "start": { + "column": 2, + "line": 1, + "offset": 1, + }, + }, + "name": "a", + }, + ], + "text": ".a {", + "tokenImporters": [], + "tokenReferences": [], + } `); }); test('does not include the token of keyframes if keyframes is false', () => { diff --git a/packages/core/src/parser/css-module-parser.ts b/packages/core/src/parser/css-module-parser.ts index 362f3cd7..64c3dcd1 100644 --- a/packages/core/src/parser/css-module-parser.ts +++ b/packages/core/src/parser/css-module-parser.ts @@ -1,8 +1,7 @@ -import type { AtRule, Declaration, Node, Root, Rule } from 'postcss'; -import { CssSyntaxError, parse } from 'postcss'; -import safeParser from 'postcss-safe-parser'; +import type { CssNode, SyntaxParseError } from 'css-tree'; import type { CSSModule, + DiagnosticSourceFile, DiagnosticWithDetachedLocation, DiagnosticWithLocation, Token, @@ -18,88 +17,106 @@ import { import { parseAtImport } from './at-import-parser.js'; import { parseAtValue } from './at-value-parser.js'; import { isComposesProp, parseComposesProp } from './composes-parser.js'; +import { offsetToPosition, parseCss, walk } from './csstree.js'; import { parseAtKeyframes } from './key-frame-parser.js'; -import { parseRule } from './rule-parser.js'; - -type AtImport = AtRule & { name: 'import' }; -type AtValue = AtRule & { name: 'value' }; -type AtKeyframes = AtRule & { name: 'keyframes' }; - -function isAtRuleNode(node: Node): node is AtRule { - return node.type === 'atrule'; -} - -function isAtImportNode(node: Node): node is AtImport { - return isAtRuleNode(node) && node.name === 'import'; -} - -function isAtValueNode(node: Node): node is AtValue { - return isAtRuleNode(node) && node.name === 'value'; -} - -function isAtKeyframesNode(node: Node): node is AtKeyframes { - return isAtRuleNode(node) && node.name === 'keyframes'; -} - -function isRuleNode(node: Node): node is Rule { - return node.type === 'rule'; -} - -function isDeclarationNode(node: Node): node is Declaration { - return node.type === 'decl'; -} +import { type ClassSelector, parseRule } from './rule-parser.js'; /** * Collect tokens from the AST. */ -function collectTokens(ast: Root, keyframes: boolean) { +function collectTokens(ast: CssNode, text: string, keyframes: boolean) { const allDiagnostics: DiagnosticWithDetachedLocation[] = []; const localTokens: Token[] = []; const tokenImporters: TokenImporter[] = []; const tokenReferences: TokenReference[] = []; - ast.walk((node) => { - if (isAtImportNode(node)) { - const parsed = parseAtImport(node); - if (parsed !== undefined) { - tokenImporters.push({ type: 'all', ...parsed }); - } - } else if (isAtValueNode(node)) { - const { atValue, diagnostics } = parseAtValue(node); - allDiagnostics.push(...diagnostics); - if (atValue === undefined) return; - if (atValue.type === 'declaration') { - localTokens.push({ name: atValue.name, loc: atValue.loc, declarationLoc: atValue.declarationLoc }); - } else if (atValue.type === 'importer') { - const { type: _, ...rest } = atValue; - tokenImporters.push({ ...rest, type: 'named' }); - } - } else if (keyframes && isAtKeyframesNode(node)) { - const { keyframe, diagnostics } = parseAtKeyframes(node); - allDiagnostics.push(...diagnostics); - if (keyframe) { - localTokens.push({ name: keyframe.name, loc: keyframe.loc, declarationLoc: keyframe.declarationLoc }); + walk(ast, (node) => { + if (node.type === 'Atrule') { + if (node.name === 'import') { + const parsed = parseAtImport(node, text); + if (parsed !== undefined) { + tokenImporters.push({ type: 'all', ...parsed }); + } + } else if (node.name === 'value') { + const { atValue, diagnostics } = parseAtValue(node); + allDiagnostics.push(...diagnostics); + if (atValue === undefined) return; + if (atValue.type === 'declaration') { + localTokens.push({ name: atValue.name, loc: atValue.loc, declarationLoc: atValue.declarationLoc }); + } else if (atValue.type === 'importer') { + const { type: _, ...rest } = atValue; + tokenImporters.push({ ...rest, type: 'named' }); + } + } else if (keyframes && node.name === 'keyframes') { + const { keyframe, diagnostics } = parseAtKeyframes(node); + allDiagnostics.push(...diagnostics); + if (keyframe) { + localTokens.push({ name: keyframe.name, loc: keyframe.loc, declarationLoc: keyframe.declarationLoc }); + } } - } else if (isRuleNode(node)) { + } else if (node.type === 'Rule') { const { classSelectors, diagnostics } = parseRule(node); allDiagnostics.push(...diagnostics); for (const classSelector of classSelectors) { localTokens.push(classSelector); } - } else if (keyframes && isDeclarationNode(node) && isAnimationNameProp(node.prop)) { - const { references, diagnostics } = parseAnimationNameProp(node); - allDiagnostics.push(...diagnostics); - tokenReferences.push(...references); - } else if (keyframes && isDeclarationNode(node) && isAnimationProp(node.prop)) { - const { references, diagnostics } = parseAnimationProp(node); - allDiagnostics.push(...diagnostics); - tokenReferences.push(...references); - } else if (isDeclarationNode(node) && isComposesProp(node.prop)) { - tokenReferences.push(...parseComposesProp(node)); + } else if (node.type === 'Declaration') { + if (keyframes && isAnimationNameProp(node.property)) { + const { references, diagnostics } = parseAnimationNameProp(node); + allDiagnostics.push(...diagnostics); + tokenReferences.push(...references); + } else if (keyframes && isAnimationProp(node.property)) { + const { references, diagnostics } = parseAnimationProp(node); + allDiagnostics.push(...diagnostics); + tokenReferences.push(...references); + } else if (isComposesProp(node.property)) { + tokenReferences.push(...parseComposesProp(node)); + } } }); return { localTokens, tokenImporters, tokenReferences, diagnostics: allDiagnostics }; } +/** + * css-tree silently auto-closes an unclosed block at EOF instead of reporting it, so detect it from the AST. + * A block is unclosed when its source does not end with `}`. + */ +function collectUnclosedBlockDiagnostics( + ast: CssNode, + text: string, + file: DiagnosticSourceFile, +): DiagnosticWithLocation[] { + const diagnostics: DiagnosticWithLocation[] = []; + walk(ast, (node) => { + if (node.type !== 'Rule' && node.type !== 'Atrule') return; + const block = node.block; + if (block === null || block.loc === undefined) return; + if (text.charAt(block.loc.end.offset - 1) === '}') return; + diagnostics.push({ + file, + start: { line: node.loc!.start.line, column: node.loc!.start.column }, + length: 1, + text: 'Unclosed block', + category: 'error', + }); + }); + return diagnostics; +} + +function toSyntaxErrorDiagnostic( + error: SyntaxParseError, + text: string, + file: DiagnosticSourceFile, +): DiagnosticWithLocation { + const position = offsetToPosition(text, { line: 1, column: 1, offset: 0 }, error.offset); + return { + file, + start: { line: position.line, column: position.column }, + length: 1, + text: error.message, + category: 'error', + }; +} + export interface ParseCSSModuleOptions { fileName: string; /** Whether to include syntax errors from diagnostics */ @@ -108,39 +125,32 @@ export interface ParseCSSModuleOptions { } /** * Parse CSS Module text. - * If a syntax error is detected in the text, it is re-parsed using `postcss-safe-parser`, and `localTokens` are collected as much as possible. + * Parsing is tolerant, so `localTokens` are collected even when the text contains syntax errors. */ export function parseCSSModule( text: string, { fileName, includeSyntaxError, keyframes }: ParseCSSModuleOptions, ): CSSModule { - let ast: Root; const diagnosticFile = { fileName, text }; - const allDiagnostics: DiagnosticWithLocation[] = []; + const syntaxErrors: DiagnosticWithLocation[] = []; + const ast = parseCss(text, { + fileName, + onParseError: includeSyntaxError + ? (error) => { + syntaxErrors.push(toSyntaxErrorDiagnostic(error, text, diagnosticFile)); + } + : undefined, + }); + if (includeSyntaxError) { - try { - ast = parse(text, { from: fileName }); - } catch (e) { - if (!(e instanceof CssSyntaxError)) throw e; - // If syntax error, try to parse with safe parser. While this incurs a cost - // due to parsing the file twice, it rarely becomes an issue since files - // with syntax errors are usually few in number. - ast = safeParser(text, { from: fileName }); - const { line, column, endColumn } = e.input!; - allDiagnostics.push({ - file: diagnosticFile, - start: { line, column }, - length: endColumn !== undefined ? endColumn - column : 1, - text: e.reason, - category: 'error', - }); - } - } else { - ast = safeParser(text, { from: fileName }); + syntaxErrors.push(...collectUnclosedBlockDiagnostics(ast, text, diagnosticFile)); } - const { localTokens, tokenImporters, tokenReferences, diagnostics } = collectTokens(ast, keyframes); - allDiagnostics.push(...diagnostics.map((diagnostic) => ({ ...diagnostic, file: diagnosticFile }))); + const { localTokens, tokenImporters, tokenReferences, diagnostics } = collectTokens(ast, text, keyframes); + const allDiagnostics: DiagnosticWithLocation[] = [ + ...syntaxErrors, + ...diagnostics.map((diagnostic) => ({ ...diagnostic, file: diagnosticFile })), + ]; return { fileName, text, @@ -150,3 +160,20 @@ export function parseCSSModule( diagnostics: allDiagnostics, }; } + +/** + * Collect the local class selectors defined in CSS Module text. + * + * Unlike {@link parseCSSModule}, the result is limited to class selectors and excludes other token + * kinds such as `@value` and `@keyframes`. It is used by the lint plugins to report unused class names. + */ +export function getClassSelectors(text: string, fileName: string): ClassSelector[] { + const ast = parseCss(text, { fileName }); + const classSelectors: ClassSelector[] = []; + walk(ast, (node) => { + if (node.type === 'Rule') { + classSelectors.push(...parseRule(node).classSelectors); + } + }); + return classSelectors; +} diff --git a/packages/core/src/parser/csstree.ts b/packages/core/src/parser/csstree.ts new file mode 100644 index 00000000..5c6aa3c3 --- /dev/null +++ b/packages/core/src/parser/csstree.ts @@ -0,0 +1,108 @@ +import { fork, tokenize as baseTokenize, tokenTypes as baseTokenTypes } from 'css-tree'; +import type { CssLocation, CssNode, List, ParseOptions, SyntaxParseError } from 'css-tree'; +import type { Location, Position } from '../type.js'; + +// `tokenize` and `tokenTypes` are shipped by css-tree but missing from `@types/css-tree`. +declare module 'css-tree' { + export function tokenize(source: string, onToken: (type: number, start: number, end: number) => void): void; + export const tokenTypes: Record; +} + +/** + * css-modules-kit treats `:local()`/`:global()` as functional pseudo-classes whose argument is a + * selector list, and `@value` as an at-rule whose prelude is left unparsed. Without these, css-tree + * would report parse errors for valid CSS Modules syntax. + */ +interface PreludeParseContext { + Raw(consumeUntil: ((code: number) => number) | null, excludeWhiteSpace: boolean): CssNode; + consumeUntilLeftCurlyBracketOrSemicolon(code: number): number; + SelectorList(): CssNode; + createSingleNodeList(node: CssNode): List; +} + +const selectorListPseudo = { + parse(this: PreludeParseContext): List { + // eslint-disable-next-line new-cap -- `SelectorList` is a css-tree parser method. + return this.createSingleNodeList(this.SelectorList()); + }, +}; + +interface CmkSyntax { + parse(text: string, options?: ParseOptions): CssNode; + walk(ast: CssNode, options: { enter: (node: CssNode) => void }): void; +} + +const forkExtension = { + atrule: { + value: { + parse: { + prelude(this: PreludeParseContext): List { + // Keep trailing whitespace so the prelude spans up to the terminating `;`, matching `declarationLoc`. + const consumeUntil = (code: number): number => this.consumeUntilLeftCurlyBracketOrSemicolon(code); + // eslint-disable-next-line new-cap -- `Raw` is a css-tree parser method. + const raw = this.Raw(consumeUntil, false); + return this.createSingleNodeList(raw); + }, + block: null, + }, + }, + }, + pseudo: { + local: selectorListPseudo, + global: selectorListPseudo, + }, +}; + +const cmk = (fork as unknown as (extension: typeof forkExtension) => CmkSyntax)(forkExtension); + +export const tokenTypes = baseTokenTypes; + +export interface ParseCssOptions { + fileName: string; + onParseError?: ((error: SyntaxParseError) => void) | undefined; +} + +export function parseCss(text: string, { fileName, onParseError }: ParseCssOptions): CssNode { + return cmk.parse(text, { positions: true, filename: fileName, onParseError }); +} + +export function walk(ast: CssNode, enter: (node: CssNode) => void): void { + cmk.walk(ast, { enter }); +} + +export function tokenize(source: string, onToken: (type: number, start: number, end: number) => void): void { + baseTokenize(source, onToken); +} + +/** Convert a css-tree location to a css-modules-kit {@link Location}. Both use exclusive ends and 0-based offsets. */ +export function toLocation(loc: CssLocation): Location { + return { + start: { line: loc.start.line, column: loc.start.column, offset: loc.start.offset }, + end: { line: loc.end.line, column: loc.end.column, offset: loc.end.offset }, + }; +} + +/** Location of a quoted string's content, excluding the surrounding quotes. Quoted strings never span lines. */ +export function stringInnerLocation(loc: CssLocation): Location { + return { + start: { line: loc.start.line, column: loc.start.column + 1, offset: loc.start.offset + 1 }, + end: { line: loc.end.line, column: loc.end.column - 1, offset: loc.end.offset - 1 }, + }; +} + +/** + * Resolve the absolute {@link Position} of `index` within `source`, where `source` itself starts at `base`. + * Used to locate ranges inside a {@link Raw} prelude that css-tree leaves unparsed. + */ +export function offsetToPosition(source: string, base: Position, index: number): Position { + let { line, column } = base; + for (let i = 0; i < index; i++) { + if (source[i] === '\n') { + line++; + column = 1; + } else { + column++; + } + } + return { line, column, offset: base.offset + index }; +} diff --git a/packages/core/src/parser/decl-value-location.ts b/packages/core/src/parser/decl-value-location.ts deleted file mode 100644 index d49511f7..00000000 --- a/packages/core/src/parser/decl-value-location.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Declaration } from 'postcss'; -import type { Location } from '../type.js'; - -/** - * Calculate the location of a range in the value of a declaration. - * @param sourceIndex The index of the range in the declaration value. - * @param length The length of the range. - */ -export function calcDeclValueLoc(decl: Declaration, sourceIndex: number, length: number): Location { - const baseLength = decl.prop.length + decl.raws.between!.length; - const startIndex = baseLength + sourceIndex; - return { - start: decl.positionBy({ index: startIndex }), - end: decl.positionBy({ index: startIndex + length }), - }; -} diff --git a/packages/core/src/parser/key-frame-parser.test.ts b/packages/core/src/parser/key-frame-parser.test.ts index a8866233..99dbfd0d 100644 --- a/packages/core/src/parser/key-frame-parser.test.ts +++ b/packages/core/src/parser/key-frame-parser.test.ts @@ -138,7 +138,6 @@ describe('parseAtKeyframes', () => { "start": { "column": 12, "line": 2, - "offset": 50, }, "text": "css-modules-kit does not support \`:local()\` wrapper for keyframes. Use \`@keyframes :local(local) {...}\` instead.", }, diff --git a/packages/core/src/parser/key-frame-parser.ts b/packages/core/src/parser/key-frame-parser.ts index 7fd87aa1..7ce6b628 100644 --- a/packages/core/src/parser/key-frame-parser.ts +++ b/packages/core/src/parser/key-frame-parser.ts @@ -1,5 +1,6 @@ -import type { AtRule } from 'postcss'; +import type { Atrule, Raw } from 'css-tree'; import type { DiagnosticWithDetachedLocation, Location } from '../type.js'; +import { toLocation } from './csstree.js'; interface KeyframeDeclaration { name: string; @@ -21,55 +22,43 @@ interface ParseAtKeyframesResult { * * CSS Modules treat keyframes as local tokens by default, similar to class names. * This parser extracts the keyframe name and its location for type generation. - * - * @param atKeyframes The @keyframes at-rule to parse - * @returns Parsed keyframe information and diagnostics */ -export function parseAtKeyframes(atKeyframes: AtRule): ParseAtKeyframesResult { - // Extract keyframe name from params - // e.g., "@keyframes fadeIn { ... }" -> keyframeName = "fadeIn" - // e.g., "@keyframes :local(slideOut) { ... }" -> keyframeName = ":local(slideOut)" - const keyframeName = atKeyframes.params; +export function parseAtKeyframes(atKeyframes: Atrule): ParseAtKeyframesResult { + const prelude = atKeyframes.prelude; + // Ignore empty keyframe names. + if (prelude === null) return { diagnostics: [] }; + + const declarationLoc = toLocation(atKeyframes.loc!); - // Ignore empty keyframe names - if (keyframeName === '') { - return { diagnostics: [] }; + // css-tree leaves `:local(...)`/`:global(...)` wrappers unparsed as a `Raw` prelude. + if (prelude.type === 'Raw') { + return parseWrappedName(prelude); } - const keyframeNameLoc = { - start: atKeyframes.positionBy({ index: `@keyframes${atKeyframes.raws.afterName!}`.length }), - end: atKeyframes.positionBy({ index: `@keyframes${atKeyframes.raws.afterName!}${keyframeName}`.length }), + const nameNode = prelude.children.first; + if (nameNode === null || nameNode.type !== 'Identifier') return { diagnostics: [] }; + return { + keyframe: { name: nameNode.name, loc: toLocation(nameNode.loc!), declarationLoc }, + diagnostics: [], }; +} - // Handle :local() and :global() wrappers - if (keyframeName.startsWith(':local(') && keyframeName.endsWith(')')) { +function parseWrappedName(prelude: Raw): ParseAtKeyframesResult { + const name = prelude.value; + const loc = prelude.loc!; + if (name.startsWith(':local(') && name.endsWith(')')) { // For simplicity of implementation, css-modules-kit does not support `:local(...)`. return { diagnostics: [ { category: 'error', - start: keyframeNameLoc.start, - length: keyframeName.length, - text: `css-modules-kit does not support \`:local()\` wrapper for keyframes. Use \`@keyframes ${keyframeName} {...}\` instead.`, + start: { line: loc.start.line, column: loc.start.column }, + length: name.length, + text: `css-modules-kit does not support \`:local()\` wrapper for keyframes. Use \`@keyframes ${name} {...}\` instead.`, }, ], }; - } else if (keyframeName.startsWith(':global(') && keyframeName.endsWith(')')) { - // Ignore keyframes wrapped in :global() - return { diagnostics: [] }; } - - return { - keyframe: { - name: keyframeName, - loc: keyframeNameLoc, - declarationLoc: { - start: atKeyframes.source!.start!, - end: atKeyframes.positionBy({ - index: atKeyframes.toString().length, - }), - }, - }, - diagnostics: [], - }; + // Ignore keyframes wrapped in `:global()` and any other unparsed prelude. + return { diagnostics: [] }; } diff --git a/packages/core/src/parser/rule-parser.test.ts b/packages/core/src/parser/rule-parser.test.ts index 0d077728..5629090d 100644 --- a/packages/core/src/parser/rule-parser.test.ts +++ b/packages/core/src/parser/rule-parser.test.ts @@ -1,206 +1,13 @@ import dedent from 'dedent'; -import selectorParser from 'postcss-selector-parser'; import { describe, expect, test } from 'vite-plus/test'; import { fakeRoot, fakeRules } from '../test/ast.js'; -import type { DiagnosticPosition } from '../type.js'; -import { calcDiagnosticsLocationForSelectorParserNodeForTest, parseRule } from './rule-parser.js'; +import { parseRule } from './rule-parser.js'; function parseRuleSimply(ruleStr: string): string[] { const [rule] = fakeRules(fakeRoot(ruleStr)); return parseRule(rule!).classSelectors.map((classSelector) => classSelector.name); } -function calcLocations(source: string) { - const [rule] = fakeRules(fakeRoot(source)); - const root = selectorParser().astSync(rule!); - const result: { node: string; type: string; start: DiagnosticPosition; length: number }[] = []; - root.walk((node) => { - const loc = calcDiagnosticsLocationForSelectorParserNodeForTest(rule!, node); - result.push({ - node: node.toString(), - type: node.type, - ...loc, - }); - }); - return result; -} - -describe('calcDiagnosticsLocationForSelectorParserNode', () => { - test('single line', () => { - const result = calcLocations('.a .b {}'); - expect(result).toMatchInlineSnapshot(` - [ - { - "length": 5, - "node": ".a .b", - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - "type": "selector", - }, - { - "length": 2, - "node": ".a", - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - "type": "class", - }, - { - "length": 1, - "node": " ", - "start": { - "column": 3, - "line": 1, - "offset": 2, - }, - "type": "combinator", - }, - { - "length": 2, - "node": ".b", - "start": { - "column": 4, - "line": 1, - "offset": 3, - }, - "type": "class", - }, - ] - `); - }); - test('multiple line', () => { - const result1 = calcLocations(dedent` - .a - .b - .c {} - `); - expect(result1).toMatchInlineSnapshot(` - [ - { - "length": 10, - "node": ".a - .b - .c", - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - "type": "selector", - }, - { - "length": 2, - "node": ".a", - "start": { - "column": 1, - "line": 1, - "offset": 0, - }, - "type": "class", - }, - { - "length": 1, - "node": " - ", - "start": { - "column": 3, - "line": 1, - "offset": 2, - }, - "type": "combinator", - }, - { - "length": 2, - "node": ".b", - "start": { - "column": 1, - "line": 2, - "offset": 3, - }, - "type": "class", - }, - { - "length": 3, - "node": " - ", - "start": { - "column": 3, - "line": 2, - "offset": 5, - }, - "type": "combinator", - }, - { - "length": 2, - "node": ".c", - "start": { - "column": 3, - "line": 3, - "offset": 8, - }, - "type": "class", - }, - ] - `); - const result2 = calcLocations(dedent` - @import './test.css'; - .a - .b {} - `); - expect(result2).toMatchInlineSnapshot(` - [ - { - "length": 7, - "node": ".a - .b", - "start": { - "column": 1, - "line": 2, - "offset": 22, - }, - "type": "selector", - }, - { - "length": 2, - "node": ".a", - "start": { - "column": 1, - "line": 2, - "offset": 22, - }, - "type": "class", - }, - { - "length": 3, - "node": " - ", - "start": { - "column": 3, - "line": 2, - "offset": 24, - }, - "type": "combinator", - }, - { - "length": 2, - "node": ".b", - "start": { - "column": 3, - "line": 3, - "offset": 27, - }, - "type": "class", - }, - ] - `); - }); -}); - describe('parseRule', () => { test('collect local class selectors', () => { const rules = fakeRules( @@ -222,7 +29,7 @@ describe('parseRule', () => { :local(.local_class_name_1) {} .with_newline_1, .with_newline_2 - + .with_newline_3, {} + + .with_newline_3 {} `), ); const result = rules.map(parseRule); @@ -666,9 +473,9 @@ describe('parseRule', () => { { "declarationLoc": { "end": { - "column": 24, + "column": 23, "line": 18, - "offset": 400, + "offset": 399, }, "start": { "column": 1, @@ -693,9 +500,9 @@ describe('parseRule', () => { { "declarationLoc": { "end": { - "column": 24, + "column": 23, "line": 18, - "offset": 400, + "offset": 399, }, "start": { "column": 1, @@ -720,9 +527,9 @@ describe('parseRule', () => { { "declarationLoc": { "end": { - "column": 24, + "column": 23, "line": 18, - "offset": 400, + "offset": 399, }, "start": { "column": 1, @@ -951,7 +758,6 @@ describe('parseRule', () => { "start": { "column": 8, "line": 1, - "offset": 7, }, "text": "A \`:global(...)\` is not allowed inside of \`:local(...)\`.", }, @@ -966,7 +772,6 @@ describe('parseRule', () => { "start": { "column": 9, "line": 2, - "offset": 31, }, "text": "A \`:local(...)\` is not allowed inside of \`:global(...)\`.", }, @@ -981,7 +786,6 @@ describe('parseRule', () => { "start": { "column": 8, "line": 3, - "offset": 53, }, "text": "A \`:local(...)\` is not allowed inside of \`:local(...)\`.", }, @@ -996,7 +800,6 @@ describe('parseRule', () => { "start": { "column": 9, "line": 4, - "offset": 76, }, "text": "A \`:global(...)\` is not allowed inside of \`:global(...)\`.", }, @@ -1020,11 +823,31 @@ describe('parseRule', () => { [ { "classSelectors": [], - "diagnostics": [], + "diagnostics": [ + { + "category": "error", + "length": 8, + "start": { + "column": 1, + "line": 1, + }, + "text": "css-modules-kit does not support \`:local\`. Use \`:local(...)\` instead.", + }, + ], }, { "classSelectors": [], - "diagnostics": [], + "diagnostics": [ + { + "category": "error", + "length": 9, + "start": { + "column": 1, + "line": 2, + }, + "text": "css-modules-kit does not support \`:global\`. Use \`:global(...)\` instead.", + }, + ], }, { "classSelectors": [], @@ -1083,7 +906,6 @@ describe('parseRule', () => { "start": { "column": 1, "line": 1, - "offset": 0, }, "text": "css-modules-kit does not support \`:local\`. Use \`:local(...)\` instead.", }, @@ -1126,7 +948,6 @@ describe('parseRule', () => { "start": { "column": 1, "line": 2, - "offset": 18, }, "text": "css-modules-kit does not support \`:global\`. Use \`:global(...)\` instead.", }, diff --git a/packages/core/src/parser/rule-parser.ts b/packages/core/src/parser/rule-parser.ts index de48bb0e..6dfeb351 100644 --- a/packages/core/src/parser/rule-parser.ts +++ b/packages/core/src/parser/rule-parser.ts @@ -1,90 +1,8 @@ -import type { Rule } from 'postcss'; -import selectorParser from 'postcss-selector-parser'; -import type { DiagnosticPosition, DiagnosticWithDetachedLocation, Location } from '../type.js'; +import type { ClassSelector as CssClassSelector, CssNode, PseudoClassSelector, Rule } from 'css-tree'; +import type { DiagnosticWithDetachedLocation, Location } from '../type.js'; +import { toLocation } from './csstree.js'; -function calcDiagnosticsLocationForSelectorParserNode( - rule: Rule, - node: selectorParser.Node, -): { start: DiagnosticPosition; length: number } { - const start = rule.positionBy({ index: node.sourceIndex }); - const length = node.toString().length; - return { start, length }; -} -export { calcDiagnosticsLocationForSelectorParserNode as calcDiagnosticsLocationForSelectorParserNodeForTest }; - -interface CollectResult { - classNames: selectorParser.ClassName[]; - diagnostics: DiagnosticWithDetachedLocation[]; -} - -function flatCollectResults(results: CollectResult[]): CollectResult { - const classNames: selectorParser.ClassName[] = []; - const diagnostics: DiagnosticWithDetachedLocation[] = []; - for (const result of results) { - classNames.push(...result.classNames); - diagnostics.push(...result.diagnostics); - } - return { classNames, diagnostics }; -} - -/** - * Collect local class names from the AST. - * This function is based on the behavior of postcss-modules-local-by-default. - * - * @see https://github.com/css-modules/postcss-modules-local-by-default/blob/38119276608ef14821797cfc0242b3c7dead69af/src/index.js - * @see https://github.com/css-modules/postcss-modules-local-by-default/blob/38119276608ef14821797cfc0242b3c7dead69af/test/index.test.js - * @example `.local1 :global(.global1) .local2 :local(.local3)` => `[".local1", ".local2", ".local3"]` - */ -function collectLocalClassNames(rule: Rule, root: selectorParser.Root): CollectResult { - return visitNode(root, undefined); - - function visitNode(node: selectorParser.Node, wrappedBy: ':local(...)' | ':global(...)' | undefined): CollectResult { - if (selectorParser.isClassName(node)) { - switch (wrappedBy) { - // If the class name is wrapped by `:local(...)` or `:global(...)`, - // the scope is determined by the wrapper. - case ':local(...)': - return { classNames: [node], diagnostics: [] }; - case ':global(...)': - return { classNames: [], diagnostics: [] }; - // If the class name is not wrapped by `:local(...)` or `:global(...)`, - // the scope is determined by the mode. - default: - // Mode is customizable in css-loader, but we don't support it for simplicity. We fix the mode to 'local'. - return { classNames: [node], diagnostics: [] }; - } - } else if (selectorParser.isPseudo(node) && (node.value === ':local' || node.value === ':global')) { - if (node.nodes.length === 0) { - // `node` is `:local` or `:global` (without any arguments) - // We don't support `:local` and `:global` (without any arguments) because they are complex. - const diagnostic: DiagnosticWithDetachedLocation = { - ...calcDiagnosticsLocationForSelectorParserNode(rule, node), - text: `css-modules-kit does not support \`${node.value}\`. Use \`${node.value}(...)\` instead.`, - category: 'error', - }; - return { classNames: [], diagnostics: [diagnostic] }; - } else { - // `node` is `:local(...)` or `:global(...)` (with arguments) - if (wrappedBy !== undefined) { - const diagnostic: DiagnosticWithDetachedLocation = { - ...calcDiagnosticsLocationForSelectorParserNode(rule, node), - text: `A \`${node.value}(...)\` is not allowed inside of \`${wrappedBy}\`.`, - category: 'error', - }; - return { classNames: [], diagnostics: [diagnostic] }; - } - return flatCollectResults( - node.nodes.map((child) => visitNode(child, node.value === ':local' ? ':local(...)' : ':global(...)')), - ); - } - } else if (selectorParser.isContainer(node)) { - return flatCollectResults(node.nodes.map((child) => visitNode(child, wrappedBy))); - } - return { classNames: [], diagnostics: [] }; - } -} - -interface ClassSelector { +export interface ClassSelector { /** The class name. It does not include the leading dot. */ name: string; /** @@ -104,42 +22,86 @@ interface ParseRuleResult { diagnostics: DiagnosticWithDetachedLocation[]; } +type Wrapper = ':local(...)' | ':global(...)' | undefined; + /** * Parse a rule and collect local class selectors. + * + * The scope handling is based on the behavior of postcss-modules-local-by-default. A class name is local + * when it is wrapped by `:local(...)` or written without a wrapper (the mode is fixed to 'local'), and + * global when wrapped by `:global(...)`. + * + * @see https://github.com/css-modules/postcss-modules-local-by-default/blob/38119276608ef14821797cfc0242b3c7dead69af/src/index.js + * @example `.local1 :global(.global1) .local2 :local(.local3)` => `[".local1", ".local2", ".local3"]` */ export function parseRule(rule: Rule): ParseRuleResult { - const root = selectorParser().astSync(rule); - const result = collectLocalClassNames(rule, root); - const classSelectors: ClassSelector[] = result.classNames.map((className) => { - // If `rule` is `.a, .b { color: red; }` and `className` is `.b`, - // `rule.source` is `{ start: { line: 1, column: 1 }, end: { line: 1, column: 22 } }` - // And `className.source` is `{ start: { line: 1, column: 5 }, end: { line: 1, column: 6 } }`. - const start = { - line: rule.source!.start!.line + className.source!.start!.line - 1, - column: rule.source!.start!.column + className.source!.start!.column, - offset: rule.source!.start!.offset + className.sourceIndex + 1, - }; - /** - * When there is a selector like `.\31 backslash`, `className.value` becomes `"1backslash"`. - * In other words, it is the string after escape sequences have been interpreted. - * However, here we need the raw string as written in the CSS source code. - * So we use `className.toString()`. - * - * The return value of `className.toString()` may contain leading dots and spaces like `" .1backslash"`. - * Therefore, we remove the leading spaces and dot with a regular expression. - */ - const rawClassName = className.toString().replace(/^\s*\./u, ''); - const end = { - // The end line is always the same as the start line, as a class selector cannot break in the middle. - line: start.line, - column: start.column + rawClassName.length, - offset: start.offset + rawClassName.length, - }; - return { - name: rawClassName, - loc: { start, end }, - declarationLoc: { start: rule.source!.start!, end: rule.positionBy({ index: rule.toString().length }) }, - }; - }); - return { classSelectors, diagnostics: result.diagnostics }; + const classNames: CssClassSelector[] = []; + const diagnostics: DiagnosticWithDetachedLocation[] = []; + + if (rule.prelude.type === 'SelectorList') { + visitNode(rule.prelude, undefined); + } + + const declarationLoc = toLocation(rule.loc!); + const classSelectors = classNames.map((node) => ({ + name: node.name, + loc: classNameLoc(node), + declarationLoc, + })); + return { classSelectors, diagnostics }; + + function visitNode(node: CssNode, wrappedBy: Wrapper): void { + if (node.type === 'ClassSelector') { + // A class name wrapped by `:global(...)` is global. Otherwise it is local, because the mode is fixed to 'local'. + if (wrappedBy !== ':global(...)') classNames.push(node); + } else if (node.type === 'PseudoClassSelector') { + if (node.name === 'local' || node.name === 'global') { + visitLocalOrGlobal(node, wrappedBy); + } else if (node.children) { + // Functional pseudo-classes like `:not(...)` and `:is(...)` keep the current scope for their arguments. + for (const child of node.children) visitNode(child, wrappedBy); + } + } else if (node.type === 'SelectorList' || node.type === 'Selector') { + for (const child of node.children) visitNode(child, wrappedBy); + } + } + + function visitLocalOrGlobal(node: PseudoClassSelector, wrappedBy: Wrapper): void { + const inner = node.children?.first; + if (!inner || inner.type !== 'SelectorList') { + // `:local` or `:global` without arguments. They are complex, so css-modules-kit does not support them. + diagnostics.push({ + ...detachedLocation(node), + text: `css-modules-kit does not support \`:${node.name}\`. Use \`:${node.name}(...)\` instead.`, + category: 'error', + }); + return; + } + if (wrappedBy !== undefined) { + diagnostics.push({ + ...detachedLocation(node), + text: `A \`:${node.name}(...)\` is not allowed inside of \`${wrappedBy}\`.`, + category: 'error', + }); + return; + } + visitNode(inner, node.name === 'local' ? ':local(...)' : ':global(...)'); + } +} + +function classNameLoc(node: CssClassSelector): Location { + const loc = node.loc!; + // Skip the leading dot, which is always a single character on the same line as the class name. + return { + start: { line: loc.start.line, column: loc.start.column + 1, offset: loc.start.offset + 1 }, + end: { line: loc.end.line, column: loc.end.column, offset: loc.end.offset }, + }; +} + +function detachedLocation(node: CssNode): { start: { line: number; column: number }; length: number } { + const loc = node.loc!; + return { + start: { line: loc.start.line, column: loc.start.column }, + length: loc.end.offset - loc.start.offset, + }; } diff --git a/packages/core/src/test/ast.ts b/packages/core/src/test/ast.ts index 3eb2cd7a..fa7a37b6 100644 --- a/packages/core/src/test/ast.ts +++ b/packages/core/src/test/ast.ts @@ -1,63 +1,40 @@ -import type { AtRule, Declaration, Root, Rule } from 'postcss'; -import { parse } from 'postcss'; -import type { ClassName } from 'postcss-selector-parser'; -import selectorParser from 'postcss-selector-parser'; +import type { Atrule, CssNode, Declaration, Rule } from 'css-tree'; +import { parseCss, walk } from '../parser/csstree.js'; -export function fakeRoot(text: string, from?: string): Root { - return parse(text, { from: from || '/test/test.css' }); +export function fakeRoot(text: string, from?: string): CssNode { + return parseCss(text, { fileName: from ?? '/test/test.css' }); } -export function fakeAtImports(root: Root): AtRule[] { - const results: AtRule[] = []; - root.walkAtRules('import', (atImport) => { - results.push(atImport); +function collectByType(root: CssNode, type: T['type']): T[] { + const results: T[] = []; + walk(root, (node) => { + if (node.type === type) results.push(node as T); }); return results; } -export function fakeAtValues(root: Root): AtRule[] { - const results: AtRule[] = []; - root.walkAtRules('value', (atValue) => { - results.push(atValue); - }); - return results; +function collectAtRules(root: CssNode, name: string): Atrule[] { + return collectByType(root, 'Atrule').filter((atrule) => atrule.name === name); } -export function fakeRules(root: Root): Rule[] { - const results: Rule[] = []; - root.walkRules((rule) => { - results.push(rule); - }); - return results; +export function fakeAtImports(root: CssNode): Atrule[] { + return collectAtRules(root, 'import'); } -export function fakeClassSelectors(root: Root): { rule: Rule; classSelector: ClassName }[] { - const results: { rule: Rule; classSelector: ClassName }[] = []; - root.walkRules((rule) => { - selectorParser((selectors) => { - selectors.walk((selector) => { - if (selector.type === 'class') { - results.push({ rule, classSelector: selector }); - } - }); - }).processSync(rule); - }); - return results; +export function fakeAtValues(root: CssNode): Atrule[] { + return collectAtRules(root, 'value'); } -export function fakeAtKeyframes(root: Root): AtRule[] { - const results: AtRule[] = []; - root.walkAtRules('keyframes', (atKeyframes) => { - results.push(atKeyframes); - }); - return results; +export function fakeAtKeyframes(root: CssNode): Atrule[] { + return collectAtRules(root, 'keyframes'); +} + +export function fakeRules(root: CssNode): Rule[] { + return collectByType(root, 'Rule'); } export function fakeDeclaration(css: string): Declaration { - const root = fakeRoot(css); - const rule = root.first; - if (rule === undefined || rule.type !== 'rule') throw new Error('expected a rule'); - const decl = rule.first; - if (decl === undefined || decl.type !== 'decl') throw new Error('expected a declaration'); + const [decl] = collectByType(fakeRoot(css), 'Declaration'); + if (decl === undefined) throw new Error('expected a declaration'); return decl; } diff --git a/packages/core/src/util.test.ts b/packages/core/src/util.test.ts index 2497a878..5084a464 100644 --- a/packages/core/src/util.test.ts +++ b/packages/core/src/util.test.ts @@ -1,8 +1,12 @@ import dedent from 'dedent'; import { describe, expect, test } from 'vite-plus/test'; -import { fakeRoot } from './test/ast.js'; +import { parseCSSModule } from './parser/css-module-parser.js'; import { findUsedTokenNames, validateTokenName } from './util.js'; +function parse(css: string) { + return parseCSSModule(css, { fileName: '/test.module.css', includeSyntaxError: false, keyframes: true }); +} + describe('validateTokenName', () => { test('returns undefined for valid token name', () => { expect(validateTokenName('validName', { namedExports: false })).toBe(undefined); @@ -40,18 +44,18 @@ describe('findUsedTokenNames', () => { styles; `; const expected = new Set(['a_1', 'a_2', 'a-3', 'a-4', 'a_6']); - expect(findUsedTokenNames(text, fakeRoot(''))).toEqual(expected); + expect(findUsedTokenNames(text, parse(''))).toEqual(expected); }); test('collects token names referenced from animation-name in the CSS module', () => { - const root = fakeRoot('.a_3 { animation-name: a_1, a_2; }'); - expect(findUsedTokenNames('styles.a_3;', root)).toEqual(new Set(['a_1', 'a_2', 'a_3'])); + const cssModule = parse('.a_3 { animation-name: a_1, a_2; }'); + expect(findUsedTokenNames('styles.a_3;', cssModule)).toEqual(new Set(['a_1', 'a_2', 'a_3'])); }); test('collects token names referenced from composes in the CSS module', () => { - const root = fakeRoot('.a_3 { composes: a_1 a_2; }'); - expect(findUsedTokenNames('styles.a_3;', root)).toEqual(new Set(['a_1', 'a_2', 'a_3'])); + const cssModule = parse('.a_3 { composes: a_1 a_2; }'); + expect(findUsedTokenNames('styles.a_3;', cssModule)).toEqual(new Set(['a_1', 'a_2', 'a_3'])); }); test('does not collect token names referenced from composes with a `from` specifier', () => { - const root = fakeRoot(`.a_2 { composes: a_1 from './b.module.css'; }`); - expect(findUsedTokenNames('styles.a_2;', root)).toEqual(new Set(['a_2'])); + const cssModule = parse(`.a_2 { composes: a_1 from './b.module.css'; }`); + expect(findUsedTokenNames('styles.a_2;', cssModule)).toEqual(new Set(['a_2'])); }); }); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 76c68aa4..23a37f52 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -1,6 +1,4 @@ -import type { Root } from 'postcss'; -import { isAnimationNameProp, parseAnimationNameProp } from './parser/animation-parser.js'; -import { isComposesProp, parseComposesProp } from './parser/composes-parser.js'; +import type { CSSModule } from './type.js'; export function isPosixRelativePath(path: string): boolean { return path.startsWith(`./`) || path.startsWith(`../`); @@ -44,31 +42,20 @@ const TOKEN_CONSUMER_PATTERN = * A token name is considered used if it is referenced from the component file * (via a `styles.` style pattern) or from within the CSS module itself * (e.g. via `animation-name: ` or `composes: `). The latter is - * collected by walking `root` so that callers can pass an already-parsed - * PostCSS AST to avoid an extra parse. + * read from the local token references of an already-parsed {@link CSSModule}. */ -export function findUsedTokenNames(componentText: string, root: Root): Set { +export function findUsedTokenNames(componentText: string, cssModule: CSSModule): Set { const usedClassNames = new Set(); for (const match of componentText.matchAll(TOKEN_CONSUMER_PATTERN)) { const name = match[1] ?? match[2] ?? match[3]; if (name) usedClassNames.add(name); } - root.walkDecls((decl) => { - let references; - if (isAnimationNameProp(decl.prop)) { - ({ references } = parseAnimationNameProp(decl)); - } else if (isComposesProp(decl.prop)) { - references = parseComposesProp(decl); - } else { - return; - } - for (const reference of references) { - // External references point to tokens of another file, so they do not - // mark same-named tokens of the current file as used. - if (reference.type !== 'local') continue; - usedClassNames.add(reference.name); - } - }); + for (const reference of cssModule.tokenReferences) { + // External references point to tokens of another file, so they do not + // mark same-named tokens of the current file as used. + if (reference.type !== 'local') continue; + usedClassNames.add(reference.name); + } return usedClassNames; } diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index d9e1f765..0c2101f8 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -41,8 +41,7 @@ "build": "tsc -b tsconfig.build.json" }, "dependencies": { - "@css-modules-kit/core": "workspace:^", - "postcss-safe-parser": "^7.0.1" + "@css-modules-kit/core": "workspace:^" }, "peerDependencies": { "@eslint/css": ">=0.4.0", diff --git a/packages/eslint-plugin/src/rules/no-unused-class-names.ts b/packages/eslint-plugin/src/rules/no-unused-class-names.ts index a2b3f89b..dd1cf335 100644 --- a/packages/eslint-plugin/src/rules/no-unused-class-names.ts +++ b/packages/eslint-plugin/src/rules/no-unused-class-names.ts @@ -1,6 +1,12 @@ -import { basename, findComponentFileSync, findUsedTokenNames, isCSSModuleFile, parseRule } from '@css-modules-kit/core'; +import { + basename, + findComponentFileSync, + findUsedTokenNames, + getClassSelectors, + isCSSModuleFile, + parseCSSModule, +} from '@css-modules-kit/core'; import type { Rule } from 'eslint'; -import safeParser from 'postcss-safe-parser'; import { readFile } from '../util.js'; export const noUnusedClassNames: Rule.RuleModule = { @@ -27,34 +33,31 @@ export const noUnusedClassNames: Rule.RuleModule = { // assumed that all class names are used. if (componentFile === undefined) return {}; - const root = safeParser(context.sourceCode.text, { from: fileName }); - const usedTokenNames = findUsedTokenNames(componentFile.text, root); + const text = context.sourceCode.text; + const cssModule = parseCSSModule(text, { fileName, includeSyntaxError: false, keyframes: true }); + const usedTokenNames = findUsedTokenNames(componentFile.text, cssModule); - root.walkRules((rule) => { - const { classSelectors } = parseRule(rule); - - for (const classSelector of classSelectors) { - if (!usedTokenNames.has(classSelector.name)) { - context.report({ - loc: { - start: { - line: classSelector.loc.start.line, - column: classSelector.loc.start.column, - }, - end: { - line: classSelector.loc.end.line, - column: classSelector.loc.end.column, - }, + for (const classSelector of getClassSelectors(text, fileName)) { + if (!usedTokenNames.has(classSelector.name)) { + context.report({ + loc: { + start: { + line: classSelector.loc.start.line, + column: classSelector.loc.start.column, }, - messageId: 'disallow', - data: { - className: classSelector.name, - componentFileName: basename(componentFile.fileName), + end: { + line: classSelector.loc.end.line, + column: classSelector.loc.end.column, }, - }); - } + }, + messageId: 'disallow', + data: { + className: classSelector.name, + componentFileName: basename(componentFile.fileName), + }, + }); } - }); + } return {}; }, }; diff --git a/packages/stylelint-plugin/src/rules/no-unused-class-names.ts b/packages/stylelint-plugin/src/rules/no-unused-class-names.ts index b65c43f9..7e31cddd 100644 --- a/packages/stylelint-plugin/src/rules/no-unused-class-names.ts +++ b/packages/stylelint-plugin/src/rules/no-unused-class-names.ts @@ -1,4 +1,11 @@ -import { basename, findComponentFile, findUsedTokenNames, isCSSModuleFile, parseRule } from '@css-modules-kit/core'; +import { + basename, + findComponentFile, + findUsedTokenNames, + getClassSelectors, + isCSSModuleFile, + parseCSSModule, +} from '@css-modules-kit/core'; import type { Rule, RuleMeta } from 'stylelint'; import stylelint from 'stylelint'; import { readFile } from '../util.js'; @@ -28,25 +35,22 @@ const ruleFunction: Rule = (_primaryOptions, _secondaryOptions, _context) => { // assumed that all class names are used. if (componentFile === undefined) return; - const usedTokenNames = findUsedTokenNames(componentFile.text, root); - - root.walkRules((rule) => { - const { classSelectors } = parseRule(rule); - - for (const classSelector of classSelectors) { - if (!usedTokenNames.has(classSelector.name)) { - utils.report({ - result, - ruleName, - message: messages.disallow(classSelector.name, componentFile.fileName), - node: rule, - index: classSelector.loc.start.offset - rule.source!.start!.offset, - endIndex: classSelector.loc.end.offset - rule.source!.start!.offset, - word: classSelector.name, - }); - } + const text = root.source!.input.css; + const cssModule = parseCSSModule(text, { fileName, includeSyntaxError: false, keyframes: true }); + const usedTokenNames = findUsedTokenNames(componentFile.text, cssModule); + + for (const classSelector of getClassSelectors(text, fileName)) { + if (!usedTokenNames.has(classSelector.name)) { + utils.report({ + result, + ruleName, + message: messages.disallow(classSelector.name, componentFile.fileName), + node: root, + start: { line: classSelector.loc.start.line, column: classSelector.loc.start.column }, + end: { line: classSelector.loc.end.line, column: classSelector.loc.end.column }, + }); } - }); + } }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2853f823..83848e46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,21 +125,16 @@ importers: packages/core: dependencies: - postcss: - specifier: ^8.5.9 - version: 8.5.9 - postcss-safe-parser: - specifier: ^7.0.1 - version: 7.0.1(postcss@8.5.9) - postcss-selector-parser: - specifier: ^7.1.1 - version: 7.1.1 - postcss-value-parser: - specifier: ^4.2.0 - version: 4.2.0 + css-tree: + specifier: ^3.2.1 + version: 3.2.1 typescript: specifier: '>=5.7.3' version: 6.0.2 + devDependencies: + '@types/css-tree': + specifier: ^2.3.11 + version: 2.3.11 packages/eslint-plugin: dependencies: @@ -152,9 +147,6 @@ importers: eslint: specifier: '>=9.0.0' version: 10.2.0 - postcss-safe-parser: - specifier: ^7.0.1 - version: 7.0.1(postcss@8.5.9) packages/stylelint-plugin: dependencies: @@ -685,10 +677,10 @@ packages: oxlint-tsgolint: '>=0.22.1' '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz} + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} '@napi-rs/wasm-runtime@1.1.3': - resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz} + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -1089,7 +1081,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} '@polka/url@1.0.0-next.29': @@ -1265,11 +1257,14 @@ packages: resolution: {integrity: sha512-sJOrlVLLXp4/EZtiWKWq9y2fWyZlI8GP+24rnU5avtPWBIMm/1w97yzKrAqYF8czx2MqR391z5akhnfhj2f/AQ==, tarball: https://registry.npmjs.org/@textlint/types/-/types-15.5.2.tgz} '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz} + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, tarball: https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz} + '@types/css-tree@2.3.11': + resolution: {integrity: sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, tarball: https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz} @@ -1798,7 +1793,7 @@ packages: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==, tarball: https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz} css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==, tarball: https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz} + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} css-what@6.2.2: @@ -2152,7 +2147,7 @@ packages: engines: {node: '>=6 <7 || >=8'} fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -3405,7 +3400,7 @@ packages: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, tarball: https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz} tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==, tarball: https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz} @@ -4561,6 +4556,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/css-tree@2.3.11': {} + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {}