Skip to content

Commit 5bc90dd

Browse files
Include filename and line numbers in CSS parse errors (#19282)
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
1 parent 5a89571 commit 5bc90dd

File tree

4 files changed

+185
-21
lines changed

4 files changed

+185
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Ensure validation of `source(…)` happens relative to the file it is in ([#19274](https://github.com/tailwindlabs/tailwindcss/pull/19274))
13+
- Include filename and line numbers in CSS parse errors ([#19282](https://github.com/tailwindlabs/tailwindcss/pull/19282))
1314

1415
### Added
1516

integrations/cli/index.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import dedent from 'dedent'
22
import os from 'node:os'
33
import path from 'node:path'
4+
import { fileURLToPath } from 'node:url'
45
import { describe } from 'vitest'
56
import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
67

8+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
9+
710
const STANDALONE_BINARY = (() => {
811
switch (os.platform()) {
912
case 'win32':
@@ -2101,6 +2104,33 @@ test(
21012104
},
21022105
)
21032106

2107+
test(
2108+
'CSS parse errors should include filename and line number',
2109+
{
2110+
fs: {
2111+
'package.json': json`
2112+
{
2113+
"dependencies": {
2114+
"tailwindcss": "workspace:^",
2115+
"@tailwindcss/cli": "workspace:^"
2116+
}
2117+
}
2118+
`,
2119+
'input.css': css`
2120+
.test {
2121+
color: red;
2122+
*/
2123+
}
2124+
`,
2125+
},
2126+
},
2127+
async ({ exec, expect }) => {
2128+
await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow(
2129+
/CssSyntaxError: .*input.css:3:3: Invalid declaration: `\*\/`/,
2130+
)
2131+
},
2132+
)
2133+
21042134
function withBOM(text: string): string {
21052135
return '\uFEFF' + text
21062136
}

packages/tailwindcss/src/css-parser.test.ts

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
88
return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n'))
99
}
1010

11+
function parseWithLoc(string: string) {
12+
return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n'), {
13+
from: 'input.css',
14+
})
15+
}
16+
1117
describe('comments', () => {
1218
it('should parse a comment and ignore it', () => {
1319
expect(
@@ -1145,7 +1151,20 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
11451151
color: blue;
11461152
}
11471153
`),
1148-
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`)
1154+
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Missing opening {]`)
1155+
1156+
expect(() =>
1157+
parseWithLoc(`
1158+
.foo {
1159+
color: red;
1160+
}
1161+
1162+
.bar
1163+
/* ^ Missing opening { */
1164+
color: blue;
1165+
}
1166+
`),
1167+
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: input.css:9:11: Missing opening {]`)
11491168
})
11501169

11511170
it('should error when curly brackets are unbalanced (closing)', () => {
@@ -1160,7 +1179,22 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
11601179
11611180
/* ^ Missing closing } */
11621181
`),
1163-
).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`)
1182+
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Missing closing } at .bar]`)
1183+
1184+
expect(() =>
1185+
parseWithLoc(`
1186+
.foo {
1187+
color: red;
1188+
}
1189+
1190+
.bar {
1191+
color: blue;
1192+
1193+
/* ^ Missing closing } */
1194+
`),
1195+
).toThrowErrorMatchingInlineSnapshot(
1196+
`[CssSyntaxError: input.css:6:11: Missing closing } at .bar]`,
1197+
)
11641198
})
11651199

11661200
it('should error when an unterminated string is used', () => {
@@ -1172,7 +1206,19 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
11721206
font-weight: bold;
11731207
}
11741208
`),
1175-
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`)
1209+
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: "Hello world!"]`)
1210+
1211+
expect(() =>
1212+
parseWithLoc(css`
1213+
.foo {
1214+
content: "Hello world!
1215+
/* ^ missing " */
1216+
font-weight: bold;
1217+
}
1218+
`),
1219+
).toThrowErrorMatchingInlineSnapshot(
1220+
`[CssSyntaxError: input.css:3:22: Unterminated string: "Hello world!"]`,
1221+
)
11761222
})
11771223

11781224
it('should error when an unterminated string is used with a `;`', () => {
@@ -1184,18 +1230,38 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
11841230
font-weight: bold;
11851231
}
11861232
`),
1187-
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`)
1233+
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: "Hello world!;"]`)
1234+
1235+
expect(() =>
1236+
parseWithLoc(css`
1237+
.foo {
1238+
content: "Hello world!;
1239+
/* ^ missing " */
1240+
font-weight: bold;
1241+
}
1242+
`),
1243+
).toThrowErrorMatchingInlineSnapshot(
1244+
`[CssSyntaxError: input.css:3:22: Unterminated string: "Hello world!;"]`,
1245+
)
11881246
})
11891247

11901248
it('should error when incomplete custom properties are used', () => {
11911249
expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot(
1192-
`[Error: Invalid custom property, expected a value]`,
1250+
`[CssSyntaxError: Invalid custom property, expected a value]`,
1251+
)
1252+
1253+
expect(() => parseWithLoc('--foo')).toThrowErrorMatchingInlineSnapshot(
1254+
`[CssSyntaxError: input.css:1:1: Invalid custom property, expected a value]`,
11931255
)
11941256
})
11951257

11961258
it('should error when incomplete custom properties are used inside rules', () => {
11971259
expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(
1198-
`[Error: Invalid custom property, expected a value]`,
1260+
`[CssSyntaxError: Invalid custom property, expected a value]`,
1261+
)
1262+
1263+
expect(() => parseWithLoc('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot(
1264+
`[CssSyntaxError: input.css:1:8: Invalid custom property, expected a value]`,
11991265
)
12001266
})
12011267

@@ -1207,12 +1273,27 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
12071273
/* ^ missing ' * /;
12081274
}
12091275
`),
1210-
).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: 'Hello world!']`)
1276+
).toThrowErrorMatchingInlineSnapshot(`[CssSyntaxError: Unterminated string: 'Hello world!']`)
1277+
1278+
expect(() =>
1279+
parseWithLoc(css`
1280+
.foo {
1281+
--bar: 'Hello world!
1282+
/* ^ missing ' * /;
1283+
}
1284+
`),
1285+
).toThrowErrorMatchingInlineSnapshot(
1286+
`[CssSyntaxError: input.css:3:20: Unterminated string: 'Hello world!']`,
1287+
)
12111288
})
12121289

12131290
it('should error when a declaration is incomplete', () => {
12141291
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
1215-
`[Error: Invalid declaration: \`bar\`]`,
1292+
`[CssSyntaxError: Invalid declaration: \`bar\`]`,
1293+
)
1294+
1295+
expect(() => parseWithLoc('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
1296+
`[CssSyntaxError: input.css:1:8: Invalid declaration: \`bar\`]`,
12161297
)
12171298
})
12181299
})

packages/tailwindcss/src/css-parser.ts

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
type Declaration,
1010
type Rule,
1111
} from './ast'
12-
import type { Source } from './source-maps/source'
12+
import { createLineTable } from './source-maps/line-table'
13+
import type { Source, SourceLocation } from './source-maps/source'
1314

1415
const BACKSLASH = 0x5c
1516
const SLASH = 0x2f
@@ -36,6 +37,30 @@ export interface ParseOptions {
3637
from?: string
3738
}
3839

40+
/**
41+
* CSS syntax error with source location information.
42+
*/
43+
export class CssSyntaxError extends Error {
44+
loc: SourceLocation | null
45+
46+
constructor(message: string, loc: SourceLocation | null) {
47+
if (loc) {
48+
let source = loc[0]
49+
let start = createLineTable(source.code).find(loc[1])
50+
message = `${source.file}:${start.line}:${start.column + 1}: ${message}`
51+
}
52+
53+
super(message)
54+
55+
this.name = 'CssSyntaxError'
56+
this.loc = loc
57+
58+
if (Error.captureStackTrace) {
59+
Error.captureStackTrace(this, CssSyntaxError)
60+
}
61+
}
62+
}
63+
3964
export function parse(input: string, opts?: ParseOptions) {
4065
let source: Source | null = opts?.from ? { file: opts.from, code: input } : null
4166

@@ -138,7 +163,7 @@ export function parse(input: string, opts?: ParseOptions) {
138163

139164
// Start of a string.
140165
else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) {
141-
let end = parseString(input, i, currentChar)
166+
let end = parseString(input, i, currentChar, source)
142167

143168
// Adjust `buffer` to include the string.
144169
buffer += input.slice(i, end + 1)
@@ -192,7 +217,7 @@ export function parse(input: string, opts?: ParseOptions) {
192217

193218
// Start of a string.
194219
else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) {
195-
j = parseString(input, j, peekChar)
220+
j = parseString(input, j, peekChar, source)
196221
}
197222

198223
// Start of a comment.
@@ -269,7 +294,12 @@ export function parse(input: string, opts?: ParseOptions) {
269294
}
270295

271296
let declaration = parseDeclaration(buffer, colonIdx)
272-
if (!declaration) throw new Error(`Invalid custom property, expected a value`)
297+
if (!declaration) {
298+
throw new CssSyntaxError(
299+
`Invalid custom property, expected a value`,
300+
source ? [source, start, i] : null,
301+
)
302+
}
273303

274304
if (source) {
275305
declaration.src = [source, start, i]
@@ -334,7 +364,10 @@ export function parse(input: string, opts?: ParseOptions) {
334364
let declaration = parseDeclaration(buffer)
335365
if (!declaration) {
336366
if (buffer.length === 0) continue
337-
throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
367+
throw new CssSyntaxError(
368+
`Invalid declaration: \`${buffer.trim()}\``,
369+
source ? [source, bufferStart, i] : null,
370+
)
338371
}
339372

340373
if (source) {
@@ -391,7 +424,7 @@ export function parse(input: string, opts?: ParseOptions) {
391424
closingBracketStack[closingBracketStack.length - 1] !== ')'
392425
) {
393426
if (closingBracketStack === '') {
394-
throw new Error('Missing opening {')
427+
throw new CssSyntaxError('Missing opening {', source ? [source, i, i] : null)
395428
}
396429

397430
closingBracketStack = closingBracketStack.slice(0, -1)
@@ -453,7 +486,12 @@ export function parse(input: string, opts?: ParseOptions) {
453486
// Attach the declaration to the parent.
454487
if (parent) {
455488
let node = parseDeclaration(buffer, colonIdx)
456-
if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
489+
if (!node) {
490+
throw new CssSyntaxError(
491+
`Invalid declaration: \`${buffer.trim()}\``,
492+
source ? [source, bufferStart, i] : null,
493+
)
494+
}
457495

458496
if (source) {
459497
node.src = [source, bufferStart, i]
@@ -492,7 +530,7 @@ export function parse(input: string, opts?: ParseOptions) {
492530
// `)`
493531
else if (currentChar === CLOSE_PAREN) {
494532
if (closingBracketStack[closingBracketStack.length - 1] !== ')') {
495-
throw new Error('Missing opening (')
533+
throw new CssSyntaxError('Missing opening (', source ? [source, i, i] : null)
496534
}
497535

498536
closingBracketStack = closingBracketStack.slice(0, -1)
@@ -534,10 +572,17 @@ export function parse(input: string, opts?: ParseOptions) {
534572
// have a leftover `parent`, then it means that we have an unterminated block.
535573
if (closingBracketStack.length > 0 && parent) {
536574
if (parent.kind === 'rule') {
537-
throw new Error(`Missing closing } at ${parent.selector}`)
575+
throw new CssSyntaxError(
576+
`Missing closing } at ${parent.selector}`,
577+
parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null,
578+
)
538579
}
580+
539581
if (parent.kind === 'at-rule') {
540-
throw new Error(`Missing closing } at ${parent.name} ${parent.params}`)
582+
throw new CssSyntaxError(
583+
`Missing closing } at ${parent.name} ${parent.params}`,
584+
parent.src ? [parent.src[0], parent.src[1], parent.src[1]] : null,
585+
)
541586
}
542587
}
543588

@@ -594,7 +639,12 @@ function parseDeclaration(
594639
)
595640
}
596641

597-
function parseString(input: string, startIdx: number, quoteChar: number): number {
642+
function parseString(
643+
input: string,
644+
startIdx: number,
645+
quoteChar: number,
646+
source: Source | null = null,
647+
): number {
598648
let peekChar: number
599649

600650
// We need to ensure that the closing quote is the same as the opening
@@ -636,8 +686,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
636686
(input.charCodeAt(i + 1) === LINE_BREAK ||
637687
(input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK))
638688
) {
639-
throw new Error(
689+
throw new CssSyntaxError(
640690
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
691+
source ? [source, startIdx, i + 1] : null,
641692
)
642693
}
643694

@@ -655,8 +706,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
655706
peekChar === LINE_BREAK ||
656707
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK)
657708
) {
658-
throw new Error(
709+
throw new CssSyntaxError(
659710
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
711+
source ? [source, startIdx, i + 1] : null,
660712
)
661713
}
662714
}

0 commit comments

Comments
 (0)