diff --git a/CHANGELOG.md b/CHANGELOG.md index 682b66628eb2..e44a8358a064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Ensure validation of `source(…)` happens relative to the file it is in ([#19274](https://github.com/tailwindlabs/tailwindcss/pull/19274)) +- Include filename and line numbers in CSS parse errors ([#19282](https://github.com/tailwindlabs/tailwindcss/pull/19282)) ### Added diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 9b666f4ec313..5f3b0fcb42ca 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -1,9 +1,12 @@ import dedent from 'dedent' import os from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { describe } from 'vitest' import { candidate, css, html, js, json, test, ts, yaml } from '../utils' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const STANDALONE_BINARY = (() => { switch (os.platform()) { case 'win32': @@ -2101,6 +2104,33 @@ test( }, ) +test( + 'CSS parse errors should include filename and line number', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'input.css': css` + .test { + color: red; + */ + } + `, + }, + }, + async ({ exec, expect }) => { + await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow( + /CssSyntaxError: .*input.css:3:3: Invalid declaration: `\*\/`/, + ) + }, +) + function withBOM(text: string): string { return '\uFEFF' + text } diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 25657d9db770..5f0aa2287ae6 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -8,6 +8,12 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n')) } + function parseWithLoc(string: string) { + return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n'), { + from: 'input.css', + }) + } + describe('comments', () => { it('should parse a comment and ignore it', () => { expect( @@ -1145,7 +1151,20 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { color: blue; } `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`) + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Missing opening {]`) + + expect(() => + parseWithLoc(` + .foo { + color: red; + } + + .bar + /* ^ Missing opening { */ + color: blue; + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: input.css:9:11: Missing opening {]`) }) it('should error when curly brackets are unbalanced (closing)', () => { @@ -1160,7 +1179,22 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { /* ^ Missing closing } */ `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`) + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Missing closing } at .bar]`) + + expect(() => + parseWithLoc(` + .foo { + color: red; + } + + .bar { + color: blue; + + /* ^ Missing closing } */ + `), + ).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:6:11: Missing closing } at .bar]`, + ) }) it('should error when an unterminated string is used', () => { @@ -1172,7 +1206,19 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { font-weight: bold; } `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`) + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: "Hello world!"]`) + + expect(() => + parseWithLoc(css` + .foo { + content: "Hello world! + /* ^ missing " */ + font-weight: bold; + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:3:22: Unterminated string: "Hello world!"]`, + ) }) it('should error when an unterminated string is used with a `;`', () => { @@ -1184,18 +1230,38 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { font-weight: bold; } `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`) + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: "Hello world!;"]`) + + expect(() => + parseWithLoc(css` + .foo { + content: "Hello world!; + /* ^ missing " */ + font-weight: bold; + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:3:22: Unterminated string: "Hello world!;"]`, + ) }) it('should error when incomplete custom properties are used', () => { expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid custom property, expected a value]`, + `[CssSyntaxError: Invalid custom property, expected a value]`, + ) + + expect(() => parseWithLoc('--foo')).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:1:1: Invalid custom property, expected a value]`, ) }) it('should error when incomplete custom properties are used inside rules', () => { expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid custom property, expected a value]`, + `[CssSyntaxError: Invalid custom property, expected a value]`, + ) + + expect(() => parseWithLoc('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:1:8: Invalid custom property, expected a value]`, ) }) @@ -1207,12 +1273,27 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { /* ^ missing ' * /; } `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: 'Hello world!']`) + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: 'Hello world!']`) + + expect(() => + parseWithLoc(css` + .foo { + --bar: 'Hello world! + /* ^ missing ' * /; + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:3:20: Unterminated string: 'Hello world!']`, + ) }) it('should error when a declaration is incomplete', () => { expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid declaration: \`bar\`]`, + `[CssSyntaxError: Invalid declaration: \`bar\`]`, + ) + + expect(() => parseWithLoc('.foo { bar }')).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css:1:8: Invalid declaration: \`bar\`]`, ) }) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 1653c3789b28..3dd5e6ffe6cf 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -9,7 +9,8 @@ import { type Declaration, type Rule, } from './ast' -import type { Source } from './source-maps/source' +import { createLineTable } from './source-maps/line-table' +import type { Source, SourceLocation } from './source-maps/source' const BACKSLASH = 0x5c const SLASH = 0x2f @@ -36,6 +37,30 @@ export interface ParseOptions { from?: string } +/** + * CSS syntax error with source location information. + */ +export class CssSyntaxError extends Error { + loc: SourceLocation | null + + constructor(message: string, loc: SourceLocation | null) { + if (loc) { + let source = loc[0] + let start = createLineTable(source.code).find(loc[1]) + message = `${source.file}:${start.line}:${start.column + 1}: ${message}` + } + + super(message) + + this.name = 'CssSyntaxError' + this.loc = loc + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CssSyntaxError) + } + } +} + export function parse(input: string, opts?: ParseOptions) { let source: Source | null = opts?.from ? { file: opts.from, code: input } : null @@ -138,7 +163,7 @@ export function parse(input: string, opts?: ParseOptions) { // Start of a string. else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { - let end = parseString(input, i, currentChar) + let end = parseString(input, i, currentChar, source) // Adjust `buffer` to include the string. buffer += input.slice(i, end + 1) @@ -192,7 +217,7 @@ export function parse(input: string, opts?: ParseOptions) { // Start of a string. else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) { - j = parseString(input, j, peekChar) + j = parseString(input, j, peekChar, source) } // Start of a comment. @@ -269,7 +294,12 @@ export function parse(input: string, opts?: ParseOptions) { } let declaration = parseDeclaration(buffer, colonIdx) - if (!declaration) throw new Error(`Invalid custom property, expected a value`) + if (!declaration) { + throw new CssSyntaxError( + `Invalid custom property, expected a value`, + source ? [source, start, i] : null, + ) + } if (source) { declaration.src = [source, start, i] @@ -334,7 +364,10 @@ export function parse(input: string, opts?: ParseOptions) { let declaration = parseDeclaration(buffer) if (!declaration) { if (buffer.length === 0) continue - throw new Error(`Invalid declaration: \`${buffer.trim()}\``) + throw new CssSyntaxError( + `Invalid declaration: \`${buffer.trim()}\``, + source ? [source, bufferStart, i] : null, + ) } if (source) { @@ -391,7 +424,7 @@ export function parse(input: string, opts?: ParseOptions) { closingBracketStack[closingBracketStack.length - 1] !== ')' ) { if (closingBracketStack === '') { - throw new Error('Missing opening {') + throw new CssSyntaxError('Missing opening {', source ? [source, i, i] : null) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -453,7 +486,12 @@ export function parse(input: string, opts?: ParseOptions) { // Attach the declaration to the parent. if (parent) { let node = parseDeclaration(buffer, colonIdx) - if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``) + if (!node) { + throw new CssSyntaxError( + `Invalid declaration: \`${buffer.trim()}\``, + source ? [source, bufferStart, i] : null, + ) + } if (source) { node.src = [source, bufferStart, i] @@ -492,7 +530,7 @@ export function parse(input: string, opts?: ParseOptions) { // `)` else if (currentChar === CLOSE_PAREN) { if (closingBracketStack[closingBracketStack.length - 1] !== ')') { - throw new Error('Missing opening (') + throw new CssSyntaxError('Missing opening (', source ? [source, i, i] : null) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -534,10 +572,17 @@ export function parse(input: string, opts?: ParseOptions) { // have a leftover `parent`, then it means that we have an unterminated block. if (closingBracketStack.length > 0 && parent) { if (parent.kind === 'rule') { - throw new Error(`Missing closing } at ${parent.selector}`) + throw new CssSyntaxError( + `Missing closing } at ${parent.selector}`, + parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null, + ) } + if (parent.kind === 'at-rule') { - throw new Error(`Missing closing } at ${parent.name} ${parent.params}`) + throw new CssSyntaxError( + `Missing closing } at ${parent.name} ${parent.params}`, + parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null, + ) } } @@ -594,7 +639,12 @@ function parseDeclaration( ) } -function parseString(input: string, startIdx: number, quoteChar: number): number { +function parseString( + input: string, + startIdx: number, + quoteChar: number, + source: Source | null = null, +): number { let peekChar: number // We need to ensure that the closing quote is the same as the opening @@ -636,8 +686,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number (input.charCodeAt(i + 1) === LINE_BREAK || (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) ) { - throw new Error( + throw new CssSyntaxError( `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, + source ? [source, startIdx, i + 1] : null, ) } @@ -655,8 +706,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number peekChar === LINE_BREAK || (peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK) ) { - throw new Error( + throw new CssSyntaxError( `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, + source ? [source, startIdx, i + 1] : null, ) } }