Skip to content
28 changes: 28 additions & 0 deletions integrations/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2104,3 +2104,31 @@
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(

Check failure on line 2130 in integrations/cli/index.test.ts

View workflow job for this annotation

GitHub Actions / Linux / cli

cli/index.test.ts > CSS parse errors should include filename and line number

AssertionError: expected [Function] to throw error matching /Invalid declaration.*at.*broken\.css:…/ but got 'Command failed: pnpm tailwindcss --in…' - Expected: /Invalid declaration.*at.*broken\.css:5:49/ + Received: "Command failed: pnpm tailwindcss --input broken.css --output dist/out.css ≈ tailwindcss v4.1.17 Error: Invalid declaration: `*/` at /tmp/tailwind-integrationsUph6jZ/broken.css:4:50 " ❯ cli/index.test.ts:2130:5 ❯ utils.ts:452:14

Check failure on line 2130 in integrations/cli/index.test.ts

View workflow job for this annotation

GitHub Actions / Linux / cli

cli/index.test.ts > CSS parse errors should include filename and line number

AssertionError: expected [Function] to throw error matching /Invalid declaration.*at.*broken\.css:…/ but got 'Command failed: pnpm tailwindcss --in…' - Expected: /Invalid declaration.*at.*broken\.css:5:49/ + Received: "Command failed: pnpm tailwindcss --input broken.css --output dist/out.css ≈ tailwindcss v4.1.17 Error: Invalid declaration: `*/` at /tmp/tailwind-integrationsiKwogS/broken.css:4:50 " ❯ cli/index.test.ts:2130:5 ❯ utils.ts:452:14

Check failure on line 2130 in integrations/cli/index.test.ts

View workflow job for this annotation

GitHub Actions / Linux / cli

cli/index.test.ts > CSS parse errors should include filename and line number

AssertionError: expected [Function] to throw error matching /Invalid declaration.*at.*broken\.css:…/ but got 'Command failed: pnpm tailwindcss --in…' - Expected: /Invalid declaration.*at.*broken\.css:5:49/ + Received: "Command failed: pnpm tailwindcss --input broken.css --output dist/out.css ≈ tailwindcss v4.1.17 Error: Invalid declaration: `*/` at /tmp/tailwind-integrationsLfjdTP/broken.css:4:50 " ❯ cli/index.test.ts:2130:5 ❯ utils.ts:452:14
/Invalid declaration.*at.*broken\.css:5:49/,
)
},
)
56 changes: 56 additions & 0 deletions packages/tailwindcss/src/css-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,54 @@
`[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(

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: Windows > errors > should include filename and line number in error messages when from option is provided

Error: snapshot function didn't throw ❯ src/css-parser.test.ts:1222: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

Error: snapshot function didn't throw ❯ src/css-parser.test.ts:1222:10
`[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(

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

View workflow job for this annotation

GitHub Actions / Linux

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

Error: Snapshot `Line endings: Windows > errors > should include filename and line number for multi-line CSS errors 1` mismatched Expected: "[Error: Invalid declaration: `*/` at styles.css:4:49]" Received: "[Error: Invalid declaration: `*/` at styles.css:4:50]" ❯ src/css-parser.test.ts:1235:10

Check failure on line 1235 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

Error: Snapshot `Line endings: Unix > errors > should include filename and line number for multi-line CSS errors 1` mismatched Expected: "[Error: Invalid declaration: `*/` at styles.css:4:49]" Received: "[Error: Invalid declaration: `*/` at styles.css:4:50]" ❯ src/css-parser.test.ts:1235:10
`[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(

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

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Windows > errors > should include filename and line number for unterminated string errors

Error: Snapshot `Line endings: Windows > errors > should include filename and line number for unterminated string errors 1` mismatched Expected: "[Error: Unterminated string: "Hello world! at string-error.css:2:12]" Received: "[Error: Unterminated string: "Hello world!" at string-error.css:2:12]" ❯ src/css-parser.test.ts:1262:10

Check failure on line 1262 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 unterminated string errors

Error: Snapshot `Line endings: Unix > errors > should include filename and line number for unterminated string errors 1` mismatched Expected: "[Error: Unterminated string: "Hello world! at string-error.css:2:12]" Received: "[Error: Unterminated string: "Hello world!" at string-error.css:2:12]" ❯ src/css-parser.test.ts:1262:10
`[Error: Unterminated string: "Hello world! at string-error.css:2:12]`,
)
})
})

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

it('should not include filename when from option is not provided', () => {
expect(() => {
CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */')
}).toThrowErrorMatchingInlineSnapshot(

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

View workflow job for this annotation

GitHub Actions / Linux

src/css-parser.test.ts > Line endings: Windows > should not include filename when from option is not provided

Error: InlineSnapshot cannot be used inside of test.each or describe.each ❯ src/css-parser.test.ts:1282:8

Check failure on line 1282 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 > should not include filename when from option is not provided

Error: InlineSnapshot cannot be used inside of test.each or describe.each ❯ src/css-parser.test.ts:1282:8
`[Error: Invalid declaration: \`*/\`]`,
)
})
})
49 changes: 37 additions & 12 deletions packages/tailwindcss/src/css-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

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