Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
Jordan Pittman
bb628d7cc9 Update error 2025-11-10 11:38:11 -05:00
Jordan Pittman
e18642a616 wip 2025-11-10 11:21:32 -05:00
Jordan Pittman
6f94fc231e Tweak code a bit 2025-11-10 11:10:36 -05:00
ish1416
53509a8231 docs: add JSDoc to CssSyntaxError class
Add minimal JSDoc documentation to satisfy docstring coverage requirement
for the exported CssSyntaxError class.
2025-11-10 13:19:30 +05:30
ish1416
d9d5ea31f3 fix: correct column number to be 1-based
createLineTable().find() returns 0-based columns per source map spec,
but users expect 1-based column numbers. Add 1 to column before
formatting error message so both line and column are 1-based and accurate.

Addresses reviewer feedback about off-by-one column numbers.
2025-11-10 13:06:08 +05:30
Ishita SIngh
2aa3682cb5
Merge branch 'main' into fix/css-parse-errors-with-location 2025-11-10 12:48:25 +05:30
ish1416
667f1c7521 fix: correct test cases for CssSyntaxError
- Use simpler CSS that actually triggers parse errors
- Fix regex patterns to match actual error output
- Remove inline snapshots from describe.each blocks
- Update CLI test to match actual error line numbers

All tests should now pass with the CssSyntaxError implementation.
2025-11-10 12:47:06 +05:30
ish1416
354b706e88 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
2025-11-08 13:48:50 +05:30
3 changed files with 184 additions and 21 deletions

View file

@ -1,9 +1,12 @@
import dedent from 'dedent' import dedent from 'dedent'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe } from 'vitest' import { describe } from 'vitest'
import { candidate, css, html, js, json, test, ts, yaml } from '../utils' import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const STANDALONE_BINARY = (() => { const STANDALONE_BINARY = (() => {
switch (os.platform()) { switch (os.platform()) {
case 'win32': case 'win32':
@ -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:2: Invalid declaration: `\*\/`/,
)
},
)
function withBOM(text: string): string { function withBOM(text: string): string {
return '\uFEFF' + text return '\uFEFF' + text
} }

View file

@ -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')) 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', () => { describe('comments', () => {
it('should parse a comment and ignore it', () => { it('should parse a comment and ignore it', () => {
expect( expect(
@ -1145,7 +1151,20 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
color: blue; 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:10: Missing opening {]`)
}) })
it('should error when curly brackets are unbalanced (closing)', () => { it('should error when curly brackets are unbalanced (closing)', () => {
@ -1160,7 +1179,22 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
/* ^ Missing closing } */ /* ^ 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:10: Missing closing } at .bar]`,
)
}) })
it('should error when an unterminated string is used', () => { it('should error when an unterminated string is used', () => {
@ -1172,7 +1206,19 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
font-weight: bold; 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:21: Unterminated string: "Hello world!"]`,
)
}) })
it('should error when an unterminated string is used with a `;`', () => { it('should error when an unterminated string is used with a `;`', () => {
@ -1184,18 +1230,38 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
font-weight: bold; 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:21: Unterminated string: "Hello world!;"]`,
)
}) })
it('should error when incomplete custom properties are used', () => { it('should error when incomplete custom properties are used', () => {
expect(() => parse('--foo')).toThrowErrorMatchingInlineSnapshot( 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:0: Invalid custom property, expected a value]`,
) )
}) })
it('should error when incomplete custom properties are used inside rules', () => { it('should error when incomplete custom properties are used inside rules', () => {
expect(() => parse('.foo { --bar }')).toThrowErrorMatchingInlineSnapshot( 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:7: Invalid custom property, expected a value]`,
) )
}) })
@ -1207,12 +1273,27 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => {
/* ^ missing ' * /; /* ^ 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:19: Unterminated string: 'Hello world!']`,
)
}) })
it('should error when a declaration is incomplete', () => { it('should error when a declaration is incomplete', () => {
expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot( expect(() => parse('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
`[Error: Invalid declaration: \`bar\`]`, `[CssSyntaxError: Invalid declaration: \`bar\`]`,
)
expect(() => parseWithLoc('.foo { bar }')).toThrowErrorMatchingInlineSnapshot(
`[CssSyntaxError: input.css: 1:7: Invalid declaration: \`bar\`]`,
) )
}) })
}) })

View file

@ -9,7 +9,8 @@ import {
type Declaration, type Declaration,
type Rule, type Rule,
} from './ast' } 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 BACKSLASH = 0x5c
const SLASH = 0x2f const SLASH = 0x2f
@ -36,6 +37,30 @@ export interface ParseOptions {
from?: string 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}: ${message}`
}
super(message)
this.name = 'CssSyntaxError'
this.loc = loc
if (Error.captureStackTrace) {
Error.captureStackTrace(this, CssSyntaxError)
}
}
}
export function parse(input: string, opts?: ParseOptions) { export function parse(input: string, opts?: ParseOptions) {
let source: Source | null = opts?.from ? { file: opts.from, code: input } : null let source: Source | null = opts?.from ? { file: opts.from, code: input } : null
@ -138,7 +163,7 @@ export function parse(input: string, opts?: ParseOptions) {
// Start of a string. // Start of a string.
else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { 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. // Adjust `buffer` to include the string.
buffer += input.slice(i, end + 1) buffer += input.slice(i, end + 1)
@ -192,7 +217,7 @@ export function parse(input: string, opts?: ParseOptions) {
// Start of a string. // Start of a string.
else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) { else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) {
j = parseString(input, j, peekChar) j = parseString(input, j, peekChar, source)
} }
// Start of a comment. // Start of a comment.
@ -269,7 +294,12 @@ export function parse(input: string, opts?: ParseOptions) {
} }
let declaration = parseDeclaration(buffer, colonIdx) 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) { if (source) {
declaration.src = [source, start, i] declaration.src = [source, start, i]
@ -334,7 +364,10 @@ export function parse(input: string, opts?: ParseOptions) {
let declaration = parseDeclaration(buffer) let declaration = parseDeclaration(buffer)
if (!declaration) { if (!declaration) {
if (buffer.length === 0) continue 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) { if (source) {
@ -391,7 +424,7 @@ export function parse(input: string, opts?: ParseOptions) {
closingBracketStack[closingBracketStack.length - 1] !== ')' closingBracketStack[closingBracketStack.length - 1] !== ')'
) { ) {
if (closingBracketStack === '') { if (closingBracketStack === '') {
throw new Error('Missing opening {') throw new CssSyntaxError('Missing opening {', source ? [source, i, i] : null)
} }
closingBracketStack = closingBracketStack.slice(0, -1) closingBracketStack = closingBracketStack.slice(0, -1)
@ -453,7 +486,12 @@ export function parse(input: string, opts?: ParseOptions) {
// Attach the declaration to the parent. // Attach the declaration to the parent.
if (parent) { if (parent) {
let node = parseDeclaration(buffer, colonIdx) 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) { if (source) {
node.src = [source, bufferStart, i] node.src = [source, bufferStart, i]
@ -492,7 +530,7 @@ export function parse(input: string, opts?: ParseOptions) {
// `)` // `)`
else if (currentChar === CLOSE_PAREN) { else if (currentChar === CLOSE_PAREN) {
if (closingBracketStack[closingBracketStack.length - 1] !== ')') { 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) closingBracketStack = closingBracketStack.slice(0, -1)
@ -534,10 +572,17 @@ export function parse(input: string, opts?: ParseOptions) {
// have a leftover `parent`, then it means that we have an unterminated block. // have a leftover `parent`, then it means that we have an unterminated block.
if (closingBracketStack.length > 0 && parent) { if (closingBracketStack.length > 0 && parent) {
if (parent.kind === 'rule') { 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') { 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,
)
} }
} }
@ -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 let peekChar: number
// We need to ensure that the closing quote is the same as the opening // 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
(input.charCodeAt(i + 1) === LINE_BREAK || (input.charCodeAt(i + 1) === LINE_BREAK ||
(input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === 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)}`, `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`,
source ? [source, startIdx, i + 1] : null,
) )
} }
@ -655,8 +706,9 @@ function parseString(input: string, startIdx: number, quoteChar: number): number
peekChar === LINE_BREAK || peekChar === LINE_BREAK ||
(peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === 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)}`, `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`,
source ? [source, startIdx, i + 1] : null,
) )
} }
} }