Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 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 @@ -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
}
97 changes: 89 additions & 8 deletions packages/tailwindcss/src/css-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)', () => {
Expand All @@ -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', () => {
Expand All @@ -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 `;`', () => {
Expand All @@ -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]`,
)
})

Expand All @@ -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\`]`,
)
})
})
Expand Down
78 changes: 65 additions & 13 deletions packages/tailwindcss/src/css-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}

Expand All @@ -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,
)
}
}
Expand Down