diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 4a796d8cf..783266616 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 1e5d9f457..4d6cd1a8a 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, ) } }