Skip to content

Commit 354b706

Browse files
committed
fix: include filename and line numbers in CSS parse errors
- Add getLineAndColumn helper to calculate position from buffer index - Add formatError helper to format errors with source location - Update all CSS parser error messages to include filename:line:column - Add comprehensive test coverage for error reporting - Maintain backward compatibility when no filename provided Fixes #19236
1 parent 4363426 commit 354b706

File tree

3 files changed

+121
-12
lines changed

3 files changed

+121
-12
lines changed

integrations/cli/index.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2104,3 +2104,31 @@ test(
21042104
function withBOM(text: string): string {
21052105
return '\uFEFF' + text
21062106
}
2107+
2108+
test(
2109+
'CSS parse errors should include filename and line number',
2110+
{
2111+
fs: {
2112+
'package.json': json`
2113+
{
2114+
"dependencies": {
2115+
"tailwindcss": "workspace:^",
2116+
"@tailwindcss/cli": "workspace:^"
2117+
}
2118+
}
2119+
`,
2120+
'broken.css': css`
2121+
/* Test file to reproduce the CSS parsing error */
2122+
.test {
2123+
color: red;
2124+
/* margin-bottom: calc(var(--spacing) * 5); */ */
2125+
}
2126+
`,
2127+
},
2128+
},
2129+
async ({ exec, expect }) => {
2130+
await expect(exec('pnpm tailwindcss --input broken.css --output dist/out.css')).rejects.toThrow(
2131+
/Invalid declaration.*at.*broken\.css:5:49/,
2132+
)
2133+
},
2134+
)

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,54 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
12151215
`[Error: Invalid declaration: \`bar\`]`,
12161216
)
12171217
})
1218+
1219+
it('should include filename and line number in error messages when from option is provided', () => {
1220+
expect(() => {
1221+
CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */', { from: 'test.css' })
1222+
}).toThrowErrorMatchingInlineSnapshot(
1223+
`[Error: Invalid declaration: \`*/\` at test.css:1:49]`,
1224+
)
1225+
})
1226+
1227+
it('should include filename and line number for multi-line CSS errors', () => {
1228+
const multiLineCss = `/* Test file */
1229+
.test {
1230+
color: red;
1231+
/* margin-bottom: calc(var(--spacing) * 5); */ */
1232+
}`
1233+
expect(() => {
1234+
CSS.parse(multiLineCss, { from: 'styles.css' })
1235+
}).toThrowErrorMatchingInlineSnapshot(
1236+
`[Error: Invalid declaration: \`*/\` at styles.css:4:49]`,
1237+
)
1238+
})
1239+
1240+
it('should include filename and line number for missing opening brace errors', () => {
1241+
const cssWithMissingBrace = `.foo {
1242+
color: red;
1243+
}
1244+
1245+
.bar
1246+
color: blue;
1247+
}`
1248+
expect(() => {
1249+
CSS.parse(cssWithMissingBrace, { from: 'broken.css' })
1250+
}).toThrowErrorMatchingInlineSnapshot(
1251+
`[Error: Missing opening { at broken.css:7:1]`,
1252+
)
1253+
})
1254+
1255+
it('should include filename and line number for unterminated string errors', () => {
1256+
const cssWithUnterminatedString = `.foo {
1257+
content: "Hello world!
1258+
font-weight: bold;
1259+
}`
1260+
expect(() => {
1261+
CSS.parse(cssWithUnterminatedString, { from: 'string-error.css' })
1262+
}).toThrowErrorMatchingInlineSnapshot(
1263+
`[Error: Unterminated string: "Hello world! at string-error.css:2:12]`,
1264+
)
1265+
})
12181266
})
12191267

12201268
it('ignores BOM at the beginning of a file', () => {
@@ -1227,4 +1275,12 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
12271275
},
12281276
])
12291277
})
1278+
1279+
it('should not include filename when from option is not provided', () => {
1280+
expect(() => {
1281+
CSS.parse('/* margin-bottom: calc(var(--spacing) * 5); */ */')
1282+
}).toThrowErrorMatchingInlineSnapshot(
1283+
`[Error: Invalid declaration: \`*/\`]`,
1284+
)
1285+
})
12301286
})

packages/tailwindcss/src/css-parser.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,31 @@ export interface ParseOptions {
3636
from?: string
3737
}
3838

39+
function getLineAndColumn(input: string, position: number): { line: number; column: number } {
40+
let line = 1
41+
let column = 1
42+
43+
for (let i = 0; i < position && i < input.length; i++) {
44+
if (input.charCodeAt(i) === LINE_BREAK) {
45+
line++
46+
column = 1
47+
} else {
48+
column++
49+
}
50+
}
51+
52+
return { line, column }
53+
}
54+
55+
function formatError(message: string, source: Source | null, position: number): string {
56+
if (!source) {
57+
return message
58+
}
59+
60+
const { line, column } = getLineAndColumn(source.code, position)
61+
return `${message} at ${source.file}:${line}:${column}`
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,7 @@ 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) throw new Error(formatError(`Invalid custom property, expected a value`, source, start))
273298

274299
if (source) {
275300
declaration.src = [source, start, i]
@@ -334,7 +359,7 @@ export function parse(input: string, opts?: ParseOptions) {
334359
let declaration = parseDeclaration(buffer)
335360
if (!declaration) {
336361
if (buffer.length === 0) continue
337-
throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
362+
throw new Error(formatError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart))
338363
}
339364

340365
if (source) {
@@ -391,7 +416,7 @@ export function parse(input: string, opts?: ParseOptions) {
391416
closingBracketStack[closingBracketStack.length - 1] !== ')'
392417
) {
393418
if (closingBracketStack === '') {
394-
throw new Error('Missing opening {')
419+
throw new Error(formatError('Missing opening {', source, i))
395420
}
396421

397422
closingBracketStack = closingBracketStack.slice(0, -1)
@@ -453,7 +478,7 @@ export function parse(input: string, opts?: ParseOptions) {
453478
// Attach the declaration to the parent.
454479
if (parent) {
455480
let node = parseDeclaration(buffer, colonIdx)
456-
if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
481+
if (!node) throw new Error(formatError(`Invalid declaration: \`${buffer.trim()}\``, source, bufferStart))
457482

458483
if (source) {
459484
node.src = [source, bufferStart, i]
@@ -492,7 +517,7 @@ export function parse(input: string, opts?: ParseOptions) {
492517
// `)`
493518
else if (currentChar === CLOSE_PAREN) {
494519
if (closingBracketStack[closingBracketStack.length - 1] !== ')') {
495-
throw new Error('Missing opening (')
520+
throw new Error(formatError('Missing opening (', source, i))
496521
}
497522

498523
closingBracketStack = closingBracketStack.slice(0, -1)
@@ -534,10 +559,10 @@ export function parse(input: string, opts?: ParseOptions) {
534559
// have a leftover `parent`, then it means that we have an unterminated block.
535560
if (closingBracketStack.length > 0 && parent) {
536561
if (parent.kind === 'rule') {
537-
throw new Error(`Missing closing } at ${parent.selector}`)
562+
throw new Error(formatError(`Missing closing } at ${parent.selector}`, source, input.length))
538563
}
539564
if (parent.kind === 'at-rule') {
540-
throw new Error(`Missing closing } at ${parent.name} ${parent.params}`)
565+
throw new Error(formatError(`Missing closing } at ${parent.name} ${parent.params}`, source, input.length))
541566
}
542567
}
543568

@@ -594,7 +619,7 @@ function parseDeclaration(
594619
)
595620
}
596621

597-
function parseString(input: string, startIdx: number, quoteChar: number): number {
622+
function parseString(input: string, startIdx: number, quoteChar: number, source: Source | null = null): number {
598623
let peekChar: number
599624

600625
// We need to ensure that the closing quote is the same as the opening
@@ -637,7 +662,7 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
637662
(input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK))
638663
) {
639664
throw new Error(
640-
`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
665+
formatError(`Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, source, startIdx)
641666
)
642667
}
643668

@@ -656,7 +681,7 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
656681
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK)
657682
) {
658683
throw new Error(
659-
`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
684+
formatError(`Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, source, startIdx)
660685
)
661686
}
662687
}

0 commit comments

Comments
 (0)