Skip to content
31 changes: 31 additions & 0 deletions integrations/cli/index.test.ts
Original file line number Diff line number Diff line change
@@ -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':
Expand Down Expand Up @@ -2104,3 +2107,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;
*/
}
`,
},
},
async ({ exec, expect }) => {
await expect(exec('pnpm tailwindcss --input broken.css --output dist/out.css')).rejects.toThrow(
/Invalid declaration.*at.*broken\.css:4:/,
)
},
)
46 changes: 46 additions & 0 deletions packages/tailwindcss/src/css-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,7 +1145,7 @@
color: blue;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`)

Check failure on line 1148 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when curly brackets are unbalanced (opening)

Error: Snapshot `Line endings: Unix > errors > should error when curly brackets are unbalanced (opening) 1` mismatched Expected: "[Error: Missing opening {]" Received: "[CssSyntaxError: Missing opening {]" ❯ src/css-parser.test.ts:1148:9
})

it('should error when curly brackets are unbalanced (closing)', () => {
Expand All @@ -1160,7 +1160,7 @@

/* ^ Missing closing } */
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`)

Check failure on line 1163 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when curly brackets are unbalanced (closing)

Error: Snapshot `Line endings: Unix > errors > should error when curly brackets are unbalanced (closing) 1` mismatched Expected: "[Error: Missing closing } at .bar]" Received: "[CssSyntaxError: Missing closing } at .bar]" ❯ src/css-parser.test.ts:1163:9
})

it('should error when an unterminated string is used', () => {
Expand All @@ -1172,7 +1172,7 @@
font-weight: bold;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`)

Check failure on line 1175 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when an unterminated string is used

Error: Snapshot `Line endings: Unix > errors > should error when an unterminated string is used 1` mismatched Expected: "[Error: Unterminated string: "Hello world!"]" Received: "[CssSyntaxError: Unterminated string: "Hello world!"]" ❯ src/css-parser.test.ts:1175:9
})

it('should error when an unterminated string is used with a `;`', () => {
Expand All @@ -1184,17 +1184,17 @@
font-weight: bold;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`)

Check failure on line 1187 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when an unterminated string is used with a `;`

Error: Snapshot `Line endings: Unix > errors > should error when an unterminated string is used with a `;` 1` mismatched Expected: "[Error: Unterminated string: "Hello world!;"]" Received: "[CssSyntaxError: Unterminated string: "Hello world!;"]" ❯ src/css-parser.test.ts:1187:9
})

it('should error when incomplete custom properties are used', () => {
expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot(

Check failure on line 1191 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when incomplete custom properties are used

Error: Snapshot `Line endings: Unix > errors > should error when incomplete custom properties are used 1` mismatched Expected: "[Error: Invalid custom property, expected a value]" Received: "[CssSyntaxError: Invalid custom property, expected a value]" ❯ src/css-parser.test.ts:1191:36
`[Error: Invalid custom property, expected a value]`,
)
})

it('should error when incomplete custom properties are used inside rules', () => {
expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(

Check failure on line 1197 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when incomplete custom properties are used inside rules

Error: Snapshot `Line endings: Unix > errors > should error when incomplete custom properties are used inside rules 1` mismatched Expected: "[Error: Invalid custom property, expected a value]" Received: "[CssSyntaxError: Invalid custom property, expected a value]" ❯ src/css-parser.test.ts:1197:45
`[Error: Invalid custom property, expected a value]`,
)
})
Expand All @@ -1207,14 +1207,54 @@
/* ^ missing ' * /;
}
`),
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: 'Hello world!']`)

Check failure on line 1210 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when an unterminated string is used in a custom property

Error: Snapshot `Line endings: Unix > errors > should error when an unterminated string is used in a custom property 1` mismatched Expected: "[Error: Unterminated string: 'Hello world!']" Received: "[CssSyntaxError: Unterminated string: 'Hello world!']" ❯ src/css-parser.test.ts:1210:9
})

it('should error when a declaration is incomplete', () => {
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(

Check failure on line 1214 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should error when a declaration is incomplete

Error: Snapshot `Line endings: Unix > errors > should error when a declaration is incomplete 1` mismatched Expected: "[Error: Invalid declaration: `bar`]" Received: "[CssSyntaxError: Invalid declaration: `bar`]" ❯ src/css-parser.test.ts:1214:43
`[Error: 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/)

Check failure on line 1222 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should include filename and line number in error messages when from option is provided

AssertionError: expected [Function] to throw error matching /CssSyntaxError: In…/` at test\.css:1:10 but got 'Invalid declaration: `*/` at test.css…' - Expected: /CssSyntaxError: Invalid declaration: `\*\/` at test\.css:1:10/ + Received: "Invalid declaration: `*/` at test.css:1:9" ❯ src/css-parser.test.ts:1222: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/)

Check failure on line 1233 in packages/tailwindcss/src/css-parser.test.ts

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Unix > errors > should include filename and line number for multi-line CSS errors

AssertionError: expected [Function] to throw error matching /CssSyntaxError: I…/` at styles\.css:4:4 but got 'Invalid declaration: `*/` at styles.c…' - Expected: /CssSyntaxError: Invalid declaration: `\*\/` at styles\.css:4:4/ + Received: "Invalid declaration: `*/` at styles.css:4:3" ❯ src/css-parser.test.ts:1233:10
})

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/)
})
})

it('ignores BOM at the beginning of a file', () => {
Expand All @@ -1227,4 +1267,10 @@
},
])
})

it('should not include filename when from option is not provided', () => {
expect(() => {
CSS.parse('.test { */ }')
}).toThrow(/CssSyntaxError: Invalid declaration: `\*\/`$/)
})
})
44 changes: 30 additions & 14 deletions packages/tailwindcss/src/css-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,6 +37,21 @@ 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) {
super(message)
} else {
const { line, column } = createLineTable(source.code).find(position)
super(`${message} at ${source.file}:${line}:${column + 1}`)
}
this.name = 'CssSyntaxError'
}
}

export function parse(input: string, opts?: ParseOptions) {
let source: Source | null = opts?.from ? { file: opts.from, code: input } : null

Expand Down Expand Up @@ -138,7 +154,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)
Expand Down Expand Up @@ -192,7 +208,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.
Expand Down Expand Up @@ -269,7 +285,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 CssSyntaxError(`Invalid custom property, expected a value`, source, start)

if (source) {
declaration.src = [source, start, i]
Expand Down Expand Up @@ -334,7 +350,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 CssSyntaxError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart)
}

if (source) {
Expand Down Expand Up @@ -391,7 +407,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, i)
}

closingBracketStack = closingBracketStack.slice(0, -1)
Expand Down Expand Up @@ -453,7 +469,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 CssSyntaxError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart)

if (source) {
node.src = [source, bufferStart, i]
Expand Down Expand Up @@ -492,7 +508,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, i)
}

closingBracketStack = closingBracketStack.slice(0, -1)
Expand Down Expand Up @@ -534,10 +550,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 CssSyntaxError(`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 CssSyntaxError(`Missing closing } at ${parent.name} ${parent.params}`, source, input.length)
}
}

Expand Down Expand Up @@ -594,7 +610,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
Expand Down Expand Up @@ -636,8 +652,8 @@ 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(
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
throw new CssSyntaxError(
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, source, startIdx
)
}

Expand All @@ -655,8 +671,8 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
peekChar === LINE_BREAK ||
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK)
) {
throw new Error(
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
throw new CssSyntaxError(
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, source, startIdx
)
}
}
Expand Down