From 354b706e88ac4d560d3804997ae8a1407edbd9dc Mon Sep 17 00:00:00 2001 From: ish1416 Date: Sat, 8 Nov 2025 13:48:50 +0530 Subject: [PATCH 01/11] fix: include filename and line numbers in CSS parse errors - Add getLineAndColumn helper to calculate position from buffer index - Add formatError helper to format errors with source location - Update all CSS parser error messages to include filename:line:column - Add comprehensive test coverage for error reporting - Maintain backward compatibility when no filename provided Fixes #19236 --- integrations/cli/index.test.ts | 28 +++++++++++ packages/tailwindcss/src/css-parser.test.ts | 56 +++++++++++++++++++++ packages/tailwindcss/src/css-parser.ts | 49 +++++++++++++----- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 9b666f4ec313..b17bc8a8f45d 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2104,3 +2104,31 @@ test( function withBOM(text: string): string { return '\uFEFF' + text } + +test( + 'CSS parse errors should include filename and line number', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'broken.css': css` + /* Test file to reproduce the CSS parsing error */ + .test { + color: red; + /* margin-bottom: calc(var(--spacing) * 5); */ */ + } + `, + }, + }, + async ({ exec, expect }) => { + await expect(exec('pnpm tailwindcss --input broken.css --output dist/out.css')).rejects.toThrow( + /Invalid declaration.*at.*broken\.css:5:49/, + ) + }, +) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 25657d9db770..10a38d9e9e95 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1215,6 +1215,54 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { `[Error: Invalid declaration: \`bar\`]`, ) }) + + it('should include filename and line number in error messages when from option is provided', () => { + expect(() => { + CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */', { from: 'test.css' }) + }).toThrowErrorMatchingInlineSnapshot( + `[Error: Invalid declaration: \`*/\` at test.css:1:49]`, + ) + }) + + it('should include filename and line number for multi-line CSS errors', () => { + const multiLineCss = `/* Test file */ +.test { + color: red; + /* margin-bottom: calc(var(--spacing) * 5); */ */ +}` + expect(() => { + CSS.parse(multiLineCss, { from: 'styles.css' }) + }).toThrowErrorMatchingInlineSnapshot( + `[Error: Invalid declaration: \`*/\` at styles.css:4:49]`, + ) + }) + + it('should include filename and line number for missing opening brace errors', () => { + const cssWithMissingBrace = `.foo { + color: red; +} + +.bar + color: blue; +}` + expect(() => { + CSS.parse(cssWithMissingBrace, { from: 'broken.css' }) + }).toThrowErrorMatchingInlineSnapshot( + `[Error: Missing opening { at broken.css:7:1]`, + ) + }) + + it('should include filename and line number for unterminated string errors', () => { + const cssWithUnterminatedString = `.foo { + content: "Hello world! + font-weight: bold; +}` + expect(() => { + CSS.parse(cssWithUnterminatedString, { from: 'string-error.css' }) + }).toThrowErrorMatchingInlineSnapshot( + `[Error: Unterminated string: "Hello world! at string-error.css:2:12]`, + ) + }) }) it('ignores BOM at the beginning of a file', () => { @@ -1227,4 +1275,12 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }, ]) }) + + it('should not include filename when from option is not provided', () => { + expect(() => { + CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */') + }).toThrowErrorMatchingInlineSnapshot( + `[Error: Invalid declaration: \`*/\`]`, + ) + }) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 1653c3789b28..1105d149b82e 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -36,6 +36,31 @@ export interface ParseOptions { from?: string } +function getLineAndColumn(input: string, position: number): { line: number; column: number } { + let line = 1 + let column = 1 + + for (let i = 0; i < position && i < input.length; i++) { + if (input.charCodeAt(i) === LINE_BREAK) { + line++ + column = 1 + } else { + column++ + } + } + + return { line, column } +} + +function formatError(message: string, source: Source | null, position: number): string { + if (!source) { + return message + } + + const { line, column } = getLineAndColumn(source.code, position) + return `${message} at ${source.file}:${line}:${column}` +} + 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,7 @@ 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 Error(formatError(`Invalid custom property, expected a value`, source, start)) if (source) { declaration.src = [source, start, i] @@ -334,7 +359,7 @@ 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 Error(formatError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart)) } if (source) { @@ -391,7 +416,7 @@ export function parse(input: string, opts?: ParseOptions) { closingBracketStack[closingBracketStack.length - 1] !== ')' ) { if (closingBracketStack === '') { - throw new Error('Missing opening {') + throw new Error(formatError('Missing opening {', source, i)) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -453,7 +478,7 @@ 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 Error(formatError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart)) if (source) { node.src = [source, bufferStart, i] @@ -492,7 +517,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 Error(formatError('Missing opening (', source, i)) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -534,10 +559,10 @@ 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 Error(formatError(`Missing closing } at ${parent.selector}`, source, input.length)) } if (parent.kind === 'at-rule') { - throw new Error(`Missing closing } at ${parent.name} ${parent.params}`) + throw new Error(formatError(`Missing closing } at ${parent.name} ${parent.params}`, source, input.length)) } } @@ -594,7 +619,7 @@ 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 @@ -637,7 +662,7 @@ function parseString(input: string, startIdx: number, quoteChar: number): number (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) ) { throw new Error( - `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, + formatError(`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, source, startIdx) ) } @@ -656,7 +681,7 @@ function parseString(input: string, startIdx: number, quoteChar: number): number (peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK) ) { throw new Error( - `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, + formatError(`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, source, startIdx) ) } } From 667f1c752116bd23b460dcee3e74866b847e7ba3 Mon Sep 17 00:00:00 2001 From: ish1416 Date: Mon, 10 Nov 2025 12:47:06 +0530 Subject: [PATCH 02/11] fix: correct test cases for CssSyntaxError - Use simpler CSS that actually triggers parse errors - Fix regex patterns to match actual error output - Remove inline snapshots from describe.each blocks - Update CLI test to match actual error line numbers All tests should now pass with the CssSyntaxError implementation. --- integrations/cli/index.test.ts | 7 ++- packages/tailwindcss/src/css-parser.test.ts | 26 ++++------- packages/tailwindcss/src/css-parser.ts | 50 ++++++++------------- 3 files changed, 32 insertions(+), 51 deletions(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index b17bc8a8f45d..adce6e71bfe6 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': @@ -2121,14 +2124,14 @@ test( /* Test file to reproduce the CSS parsing error */ .test { color: red; - /* margin-bottom: calc(var(--spacing) * 5); */ */ + */ } `, }, }, async ({ exec, expect }) => { await expect(exec('pnpm tailwindcss --input broken.css --output dist/out.css')).rejects.toThrow( - /Invalid declaration.*at.*broken\.css:5:49/, + /Invalid declaration.*at.*broken\.css:4:/, ) }, ) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 10a38d9e9e95..f65d51e0f572 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1218,23 +1218,19 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { it('should include filename and line number in error messages when from option is provided', () => { expect(() => { - CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */', { from: 'test.css' }) - }).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid declaration: \`*/\` at test.css:1:49]`, - ) + CSS.parse('.test { */ }', { from: 'test.css' }) + }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at test\.css:1:9/) }) it('should include filename and line number for multi-line CSS errors', () => { const multiLineCss = `/* Test file */ .test { color: red; - /* margin-bottom: calc(var(--spacing) * 5); */ */ + */ }` expect(() => { CSS.parse(multiLineCss, { from: 'styles.css' }) - }).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid declaration: \`*/\` at styles.css:4:49]`, - ) + }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at styles\.css:4:3/) }) it('should include filename and line number for missing opening brace errors', () => { @@ -1247,9 +1243,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }` expect(() => { CSS.parse(cssWithMissingBrace, { from: 'broken.css' }) - }).toThrowErrorMatchingInlineSnapshot( - `[Error: Missing opening { at broken.css:7:1]`, - ) + }).toThrow(/CssSyntaxError: Missing opening \{ at broken\.css:7:1/) }) it('should include filename and line number for unterminated string errors', () => { @@ -1259,9 +1253,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }` expect(() => { CSS.parse(cssWithUnterminatedString, { from: 'string-error.css' }) - }).toThrowErrorMatchingInlineSnapshot( - `[Error: Unterminated string: "Hello world! at string-error.css:2:12]`, - ) + }).toThrow(/CssSyntaxError: Unterminated string: "Hello world!" at string-error\.css:2:12/) }) }) @@ -1278,9 +1270,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { it('should not include filename when from option is not provided', () => { expect(() => { - CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */') - }).toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid declaration: \`*/\`]`, - ) + CSS.parse('.test { */ }') + }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/`$/) }) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 1105d149b82e..000a9e11e0df 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -9,6 +9,7 @@ import { type Declaration, type Rule, } from './ast' +import { createLineTable } from './source-maps/line-table' import type { Source } from './source-maps/source' const BACKSLASH = 0x5c @@ -36,29 +37,16 @@ export interface ParseOptions { from?: string } -function getLineAndColumn(input: string, position: number): { line: number; column: number } { - let line = 1 - let column = 1 - - for (let i = 0; i < position && i < input.length; i++) { - if (input.charCodeAt(i) === LINE_BREAK) { - line++ - column = 1 +export class CssSyntaxError extends Error { + constructor(message: string, source: Source | null, position: number) { + if (!source) { + super(message) } else { - column++ + const { line, column } = createLineTable(source.code).find(position) + super(`${message} at ${source.file}:${line}:${column}`) } + this.name = 'CssSyntaxError' } - - return { line, column } -} - -function formatError(message: string, source: Source | null, position: number): string { - if (!source) { - return message - } - - const { line, column } = getLineAndColumn(source.code, position) - return `${message} at ${source.file}:${line}:${column}` } export function parse(input: string, opts?: ParseOptions) { @@ -294,7 +282,7 @@ export function parse(input: string, opts?: ParseOptions) { } let declaration = parseDeclaration(buffer, colonIdx) - if (!declaration) throw new Error(formatError(`Invalid custom property, expected a value`, source, start)) + if (!declaration) throw new CssSyntaxError(`Invalid custom property, expected a value`, source, start) if (source) { declaration.src = [source, start, i] @@ -359,7 +347,7 @@ export function parse(input: string, opts?: ParseOptions) { let declaration = parseDeclaration(buffer) if (!declaration) { if (buffer.length === 0) continue - throw new Error(formatError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart)) + throw new CssSyntaxError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart) } if (source) { @@ -416,7 +404,7 @@ export function parse(input: string, opts?: ParseOptions) { closingBracketStack[closingBracketStack.length - 1] !== ')' ) { if (closingBracketStack === '') { - throw new Error(formatError('Missing opening {', source, i)) + throw new CssSyntaxError('Missing opening {', source, i) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -478,7 +466,7 @@ 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(formatError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart)) + if (!node) throw new CssSyntaxError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart) if (source) { node.src = [source, bufferStart, i] @@ -517,7 +505,7 @@ export function parse(input: string, opts?: ParseOptions) { // `)` else if (currentChar === CLOSE_PAREN) { if (closingBracketStack[closingBracketStack.length - 1] !== ')') { - throw new Error(formatError('Missing opening (', source, i)) + throw new CssSyntaxError('Missing opening (', source, i) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -559,10 +547,10 @@ 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(formatError(`Missing closing } at ${parent.selector}`, source, input.length)) + throw new CssSyntaxError(`Missing closing } at ${parent.selector}`, source, input.length) } if (parent.kind === 'at-rule') { - throw new Error(formatError(`Missing closing } at ${parent.name} ${parent.params}`, source, input.length)) + throw new CssSyntaxError(`Missing closing } at ${parent.name} ${parent.params}`, source, input.length) } } @@ -661,8 +649,8 @@ function parseString(input: string, startIdx: number, quoteChar: number, source: (input.charCodeAt(i + 1) === LINE_BREAK || (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) ) { - throw new Error( - formatError(`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, source, startIdx) + throw new CssSyntaxError( + `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, source, startIdx ) } @@ -680,8 +668,8 @@ function parseString(input: string, startIdx: number, quoteChar: number, source: peekChar === LINE_BREAK || (peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK) ) { - throw new Error( - formatError(`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, source, startIdx) + throw new CssSyntaxError( + `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, source, startIdx ) } } From d9d5ea31f3226ceab05462c3c19db6e2c4cfdaab Mon Sep 17 00:00:00 2001 From: ish1416 Date: Mon, 10 Nov 2025 13:05:38 +0530 Subject: [PATCH 03/11] fix: correct column number to be 1-based createLineTable().find() returns 0-based columns per source map spec, but users expect 1-based column numbers. Add 1 to column before formatting error message so both line and column are 1-based and accurate. Addresses reviewer feedback about off-by-one column numbers. --- packages/tailwindcss/src/css-parser.test.ts | 8 ++++---- packages/tailwindcss/src/css-parser.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index f65d51e0f572..4a796d8cf360 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1219,7 +1219,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { it('should include filename and line number in error messages when from option is provided', () => { expect(() => { CSS.parse('.test { */ }', { from: 'test.css' }) - }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at test\.css:1:9/) + }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at test\.css:1:10/) }) it('should include filename and line number for multi-line CSS errors', () => { @@ -1230,7 +1230,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }` expect(() => { CSS.parse(multiLineCss, { from: 'styles.css' }) - }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at styles\.css:4:3/) + }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at styles\.css:4:4/) }) it('should include filename and line number for missing opening brace errors', () => { @@ -1243,7 +1243,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }` expect(() => { CSS.parse(cssWithMissingBrace, { from: 'broken.css' }) - }).toThrow(/CssSyntaxError: Missing opening \{ at broken\.css:7:1/) + }).toThrow(/CssSyntaxError: Missing opening \{ at broken\.css:7:2/) }) it('should include filename and line number for unterminated string errors', () => { @@ -1253,7 +1253,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }` expect(() => { CSS.parse(cssWithUnterminatedString, { from: 'string-error.css' }) - }).toThrow(/CssSyntaxError: Unterminated string: "Hello world!" at string-error\.css:2:12/) + }).toThrow(/CssSyntaxError: Unterminated string: "Hello world!" at string-error\.css:2:13/) }) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 000a9e11e0df..6561816faaef 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -43,7 +43,7 @@ export class CssSyntaxError extends Error { super(message) } else { const { line, column } = createLineTable(source.code).find(position) - super(`${message} at ${source.file}:${line}:${column}`) + super(`${message} at ${source.file}:${line}:${column + 1}`) } this.name = 'CssSyntaxError' } From 53509a8231b7a03d691f4046ecb346111b89baf3 Mon Sep 17 00:00:00 2001 From: ish1416 Date: Mon, 10 Nov 2025 13:19:30 +0530 Subject: [PATCH 04/11] docs: add JSDoc to CssSyntaxError class Add minimal JSDoc documentation to satisfy docstring coverage requirement for the exported CssSyntaxError class. --- packages/tailwindcss/src/css-parser.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 6561816faaef..1e5d9f4579b2 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -37,6 +37,9 @@ export interface ParseOptions { from?: string } +/** + * CSS syntax error with source location information. + */ export class CssSyntaxError extends Error { constructor(message: string, source: Source | null, position: number) { if (!source) { From 6f94fc231ed910f8955dafdb5b55c0c53aade823 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 09:18:55 -0500 Subject: [PATCH 05/11] Tweak code a bit --- packages/tailwindcss/src/css-parser.test.ts | 141 ++++++++++++-------- packages/tailwindcss/src/css-parser.ts | 70 +++++++--- 2 files changed, 141 insertions(+), 70 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 4a796d8cf360..78326661678e 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:10: 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: 7:12: 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:21: 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:21: 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:0: 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:7: Invalid custom property, expected a value]`, ) }) @@ -1207,53 +1273,28 @@ 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:19: 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\`]`, ) - }) - - it('should include filename and line number in error messages when from option is provided', () => { - expect(() => { - CSS.parse('.test { */ }', { from: 'test.css' }) - }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at test\.css:1:10/) - }) - - it('should include filename and line number for multi-line CSS errors', () => { - const multiLineCss = `/* Test file */ -.test { - color: red; - */ -}` - expect(() => { - CSS.parse(multiLineCss, { from: 'styles.css' }) - }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/` at styles\.css:4:4/) - }) - - it('should include filename and line number for missing opening brace errors', () => { - const cssWithMissingBrace = `.foo { - color: red; -} - -.bar - color: blue; -}` - expect(() => { - CSS.parse(cssWithMissingBrace, { from: 'broken.css' }) - }).toThrow(/CssSyntaxError: Missing opening \{ at broken\.css:7:2/) - }) - it('should include filename and line number for unterminated string errors', () => { - const cssWithUnterminatedString = `.foo { - content: "Hello world! - font-weight: bold; -}` - expect(() => { - CSS.parse(cssWithUnterminatedString, { from: 'string-error.css' }) - }).toThrow(/CssSyntaxError: Unterminated string: "Hello world!" at string-error\.css:2:13/) + expect(() => parseWithLoc('.foo { bar }')).toThrowErrorMatchingInlineSnapshot( + `[CssSyntaxError: input.css: 1:7: Invalid declaration: \`bar\`]`, + ) }) }) @@ -1267,10 +1308,4 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }, ]) }) - - it('should not include filename when from option is not provided', () => { - expect(() => { - CSS.parse('.test { */ }') - }).toThrow(/CssSyntaxError: Invalid declaration: `\*\/`$/) - }) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 1e5d9f4579b2..4d6cd1a8a469 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -10,7 +10,7 @@ import { type Rule, } from './ast' import { createLineTable } from './source-maps/line-table' -import type { Source } from './source-maps/source' +import type { Source, SourceLocation } from './source-maps/source' const BACKSLASH = 0x5c const SLASH = 0x2f @@ -41,14 +41,23 @@ export interface ParseOptions { * CSS syntax error with source location information. */ export class CssSyntaxError extends Error { - constructor(message: string, source: Source | null, position: number) { - if (!source) { - super(message) - } else { - const { line, column } = createLineTable(source.code).find(position) - super(`${message} at ${source.file}:${line}:${column + 1}`) + 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}: ${message}` } + + super(message) + this.name = 'CssSyntaxError' + this.loc = loc + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CssSyntaxError) + } } } @@ -285,7 +294,12 @@ export function parse(input: string, opts?: ParseOptions) { } let declaration = parseDeclaration(buffer, colonIdx) - if (!declaration) throw new CssSyntaxError(`Invalid custom property, expected a value`, source, start) + if (!declaration) { + throw new CssSyntaxError( + `Invalid custom property, expected a value`, + source ? [source, start, i] : null, + ) + } if (source) { declaration.src = [source, start, i] @@ -350,7 +364,10 @@ export function parse(input: string, opts?: ParseOptions) { let declaration = parseDeclaration(buffer) if (!declaration) { if (buffer.length === 0) continue - throw new CssSyntaxError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart) + throw new CssSyntaxError( + `Invalid declaration: \`${buffer.trim()}\``, + source ? [source, bufferStart, i] : null, + ) } if (source) { @@ -407,7 +424,7 @@ export function parse(input: string, opts?: ParseOptions) { closingBracketStack[closingBracketStack.length - 1] !== ')' ) { if (closingBracketStack === '') { - throw new CssSyntaxError('Missing opening {', source, i) + throw new CssSyntaxError('Missing opening {', source ? [source, i, i] : null) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -469,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 CssSyntaxError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart) + if (!node) { + throw new CssSyntaxError( + `Invalid declaration: \`${buffer.trim()}\``, + source ? [source, bufferStart, i] : null, + ) + } if (source) { node.src = [source, bufferStart, i] @@ -508,7 +530,7 @@ export function parse(input: string, opts?: ParseOptions) { // `)` else if (currentChar === CLOSE_PAREN) { if (closingBracketStack[closingBracketStack.length - 1] !== ')') { - throw new CssSyntaxError('Missing opening (', source, i) + throw new CssSyntaxError('Missing opening (', source ? [source, i, i] : null) } closingBracketStack = closingBracketStack.slice(0, -1) @@ -550,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 CssSyntaxError(`Missing closing } at ${parent.selector}`, source, input.length) + throw new CssSyntaxError( + `Missing closing } at ${parent.selector}`, + source ? [source, bufferStart, bufferStart] : null, + ) } + if (parent.kind === 'at-rule') { - throw new CssSyntaxError(`Missing closing } at ${parent.name} ${parent.params}`, source, input.length) + throw new CssSyntaxError( + `Missing closing } at ${parent.name} ${parent.params}`, + source ? [source, bufferStart, bufferStart] : null, + ) } } @@ -610,7 +639,12 @@ function parseDeclaration( ) } -function parseString(input: string, startIdx: number, quoteChar: number, source: Source | null = null): 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 @@ -653,7 +687,8 @@ function parseString(input: string, startIdx: number, quoteChar: number, source: (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) ) { throw new CssSyntaxError( - `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, source, startIdx + `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, + source ? [source, startIdx, i + 1] : null, ) } @@ -672,7 +707,8 @@ function parseString(input: string, startIdx: number, quoteChar: number, source: (peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK) ) { throw new CssSyntaxError( - `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, source, startIdx + `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, + source ? [source, startIdx, i + 1] : null, ) } } From e18642a61626d850027acc51008fd636b75df07c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 11:21:32 -0500 Subject: [PATCH 06/11] wip --- integrations/cli/index.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index adce6e71bfe6..8705e81e7e22 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2104,10 +2104,6 @@ test( }, ) -function withBOM(text: string): string { - return '\uFEFF' + text -} - test( 'CSS parse errors should include filename and line number', { @@ -2120,8 +2116,7 @@ test( } } `, - 'broken.css': css` - /* Test file to reproduce the CSS parsing error */ + 'input.css': css` .test { color: red; */ @@ -2130,8 +2125,12 @@ test( }, }, async ({ exec, expect }) => { - await expect(exec('pnpm tailwindcss --input broken.css --output dist/out.css')).rejects.toThrow( - /Invalid declaration.*at.*broken\.css:4:/, + await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow( + /CssSyntaxError: .*input.css: 3:2: Invalid declaration: `\*\/`/, ) }, ) + +function withBOM(text: string): string { + return '\uFEFF' + text +} From bb628d7cc95cc09c1b764298d7f30c71068dc7c4 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 11:38:11 -0500 Subject: [PATCH 07/11] Update error --- packages/tailwindcss/src/css-parser.test.ts | 2 +- packages/tailwindcss/src/css-parser.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 78326661678e..500b08c7e91d 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1193,7 +1193,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { /* ^ Missing closing } */ `), ).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 7:12: Missing closing } at .bar]`, + `[CssSyntaxError: input.css: 6:10: Missing closing } at .bar]`, ) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index 4d6cd1a8a469..b22cf016e0a2 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -574,14 +574,14 @@ export function parse(input: string, opts?: ParseOptions) { if (parent.kind === 'rule') { throw new CssSyntaxError( `Missing closing } at ${parent.selector}`, - source ? [source, bufferStart, bufferStart] : null, + parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null, ) } if (parent.kind === 'at-rule') { throw new CssSyntaxError( `Missing closing } at ${parent.name} ${parent.params}`, - source ? [source, bufferStart, bufferStart] : null, + parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null, ) } } From 84428689bbc5e745845cbffea5e9e7b7ad00ba38 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 11:45:28 -0500 Subject: [PATCH 08/11] fix off-by-one error createLineTable really needs to be adjusted so everything is zero based instead of the current impl --- packages/tailwindcss/src/css-parser.test.ts | 16 ++++++++-------- packages/tailwindcss/src/css-parser.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 500b08c7e91d..5f0aa2287ae6 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1164,7 +1164,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { color: blue; } `), - ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: input.css: 9:10: Missing opening {]`) + ).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: input.css:9:11: Missing opening {]`) }) it('should error when curly brackets are unbalanced (closing)', () => { @@ -1193,7 +1193,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { /* ^ Missing closing } */ `), ).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 6:10: Missing closing } at .bar]`, + `[CssSyntaxError: input.css:6:11: Missing closing } at .bar]`, ) }) @@ -1217,7 +1217,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { } `), ).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 3:21: Unterminated string: "Hello world!"]`, + `[CssSyntaxError: input.css:3:22: Unterminated string: "Hello world!"]`, ) }) @@ -1241,7 +1241,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { } `), ).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 3:21: Unterminated string: "Hello world!;"]`, + `[CssSyntaxError: input.css:3:22: Unterminated string: "Hello world!;"]`, ) }) @@ -1251,7 +1251,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ) expect(() => parseWithLoc('--foo')).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 1:0: Invalid custom property, expected a value]`, + `[CssSyntaxError: input.css:1:1: Invalid custom property, expected a value]`, ) }) @@ -1261,7 +1261,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ) expect(() => parseWithLoc('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 1:7: Invalid custom property, expected a value]`, + `[CssSyntaxError: input.css:1:8: Invalid custom property, expected a value]`, ) }) @@ -1283,7 +1283,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { } `), ).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 3:19: Unterminated string: 'Hello world!']`, + `[CssSyntaxError: input.css:3:20: Unterminated string: 'Hello world!']`, ) }) @@ -1293,7 +1293,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ) expect(() => parseWithLoc('.foo { bar }')).toThrowErrorMatchingInlineSnapshot( - `[CssSyntaxError: input.css: 1:7: Invalid declaration: \`bar\`]`, + `[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 b22cf016e0a2..3dd5e6ffe6cf 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -47,7 +47,7 @@ export class CssSyntaxError extends Error { if (loc) { let source = loc[0] let start = createLineTable(source.code).find(loc[1]) - message = `${source.file}: ${start.line}:${start.column}: ${message}` + message = `${source.file}:${start.line}:${start.column + 1}: ${message}` } super(message) From 34a21fcbd74e47fd723f8fe5806bf5c12bb6b2ac Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 11:50:58 -0500 Subject: [PATCH 09/11] fix forgot to update the integration test --- integrations/cli/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 8705e81e7e22..2a7547e10630 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2126,7 +2126,7 @@ test( }, async ({ exec, expect }) => { await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow( - /CssSyntaxError: .*input.css: 3:2: Invalid declaration: `\*\/`/, + /CssSyntaxError: .*input.css:3:2: Invalid declaration: `\*\/`/, ) }, ) From 9bfa505085a84c280c8cffb9aa34b5957115943f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 12:05:40 -0500 Subject: [PATCH 10/11] wip --- integrations/cli/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 2a7547e10630..5f3b0fcb42ca 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2126,7 +2126,7 @@ test( }, async ({ exec, expect }) => { await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow( - /CssSyntaxError: .*input.css:3:2: Invalid declaration: `\*\/`/, + /CssSyntaxError: .*input.css:3:3: Invalid declaration: `\*\/`/, ) }, ) From 061b1a530a9b4f08b0306312b3455559ff650804 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 10 Nov 2025 13:06:12 -0500 Subject: [PATCH 11/11] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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