Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"extends": ["@projectwallace/preset-oxlint"]
"extends": ["./node_modules/@projectwallace/preset-oxlint/index.json"]
}
346 changes: 182 additions & 164 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@
},
"devDependencies": {
"@codecov/rollup-plugin": "^1.9.1",
"@projectwallace/preset-oxlint": "^0.0.7",
"@projectwallace/preset-oxlint": "^0.0.10",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.0.3",
"oxfmt": "^0.43.0",
"oxfmt": "^0.47.0",
"oxlint": "^1.24.0",
"publint": "^0.3.15",
"tsdown": "^0.21.0",
Expand Down
48 changes: 30 additions & 18 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ describe('parse_arguments', () => {
})

test('path traversal ../../etc throws', () => {
expect(() => parse_arguments(['../../etc'])).toThrowError()
expect(() => parse_arguments(['../../etc'])).toThrow('Invalid path: ../../etc')
})

test('path traversal ../sibling throws', () => {
expect(() => parse_arguments(['../sibling/file.css'])).toThrowError()
expect(() => parse_arguments(['../sibling/file.css'])).toThrow(
'Invalid path: ../sibling/file.css',
)
})

test('multiple valid files are all resolved', () => {
Expand Down Expand Up @@ -52,32 +54,42 @@ describe('parse_arguments', () => {
})

test('--tab-size=0 throws', () => {
expect(() => parse_arguments(['--tab-size=0'])).toThrowError()
expect(() => parse_arguments(['--tab-size=0'])).toThrow(
'--tab-size must be a positive integer',
)
})

test('--tab-size=-1 throws', () => {
expect(() => parse_arguments(['--tab-size=-1'])).toThrowError()
expect(() => parse_arguments(['--tab-size=-1'])).toThrow(
'--tab-size must be a positive integer',
)
})

test('--tab-size=abc throws', () => {
expect(() => parse_arguments(['--tab-size=abc'])).toThrowError()
expect(() => parse_arguments(['--tab-size=abc'])).toThrow(
'--tab-size must be a positive integer',
)
})
})

test('unknown flag throws', () => {
expect(() => parse_arguments(['--unknown'])).toThrowError()
expect(() => parse_arguments(['--unknown'])).toThrow(
`Unknown option '--unknown'. To specify a positional argument starting with a '-', place it at the end of the command after '--', as in '-- "--unknown"`,
)
})
})

describe('run', () => {
function make_io(overrides: Partial<Parameters<typeof run>[1]> = {}) {
return {
readFile: vi.fn(() => 'a{color:red}'),
readStdin: vi.fn(async () => 'a{color:red}'),
write: vi.fn(),
isTTY: false,
...overrides,
}
return Object.assign(
{
readFile: vi.fn<() => string>(() => 'a{color:red}'),
readStdin: vi.fn<() => Promise<string>>(() => Promise.resolve('a{color:red}')),
write: vi.fn<(s: string) => void>(),
isTTY: false,
},
overrides,
)
}

test('--help shows help text', async () => {
Expand Down Expand Up @@ -108,34 +120,34 @@ describe('run', () => {
})

test('formats file and writes output', async () => {
let io = make_io({ readFile: vi.fn(() => 'a{color:red}') })
let io = make_io({ readFile: vi.fn<() => string>(() => 'a{color:red}') })
await run(['styles.css'], io)
expect(io.readFile).toHaveBeenCalledOnce()
expect(io.write).toHaveBeenCalledOnce()
expect(io.write.mock.calls[0][0]).toContain('color: red')
})

test('formats multiple files', async () => {
let io = make_io({ readFile: vi.fn(() => 'a{color:red}') })
let io = make_io({ readFile: vi.fn<() => string>(() => 'a{color:red}') })
await run(['a.css', 'b.css'], io)
expect(io.readFile).toHaveBeenCalledTimes(2)
expect(io.write).toHaveBeenCalledTimes(2)
})

test('--minify minifies the output', async () => {
let io = make_io({ readFile: vi.fn(() => 'a { color: red; }') })
let io = make_io({ readFile: vi.fn<() => string>(() => 'a { color: red; }') })
await run(['styles.css', '--minify'], io)
expect(io.write.mock.calls[0][0]).toBe('a{color:red}')
})

test('--tab-size=2 uses 2-space indentation', async () => {
let io = make_io({ readFile: vi.fn(() => 'a{color:red}') })
let io = make_io({ readFile: vi.fn<() => string>(() => 'a{color:red}') })
await run(['styles.css', '--tab-size=2'], io)
expect(io.write.mock.calls[0][0]).toContain(' color')
})

test('path traversal throws', async () => {
let io = make_io()
await expect(run(['../../etc/passwd'], io)).rejects.toThrow()
await expect(run(['../../etc/passwd'], io)).rejects.toThrow('Invalid path: ../../etc/passwd')
})
})
9 changes: 5 additions & 4 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
// oxlint-disable no-console

import { parseArgs, styleText } from 'node:util'
import { readFileSync } from 'node:fs'
Expand Down Expand Up @@ -100,10 +101,10 @@ export async function run(args: string[], io: CliIO): Promise<void> {
for (const file of files) {
io.write(format(io.readFile(file), options))
}
} else if (!io.isTTY) {
io.write(format(await io.readStdin(), options))
} else {
} else if (io.isTTY) {
io.write(help() + '\n')
} else {
io.write(format(await io.readStdin(), options))
}
}

Expand All @@ -115,7 +116,7 @@ async function read_stdin(): Promise<string> {
return Buffer.concat(chunks).toString('utf-8')
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
if (process.argv[1] === import.meta.filename) {
try {
await run(process.argv.slice(2), {
readFile: (path) => readFileSync(path, 'utf-8'),
Expand Down
38 changes: 17 additions & 21 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export type FormatOptions = {
}

export function unquote(str: string): string {
return str.replace(/(?:^['"])|(?:['"]$)/g, EMPTY_STRING)
return str.replaceAll(/(?:^['"])|(?:['"]$)/g, EMPTY_STRING)
}

function print_string(str: string | number | null): string {
Expand Down Expand Up @@ -106,12 +106,8 @@ function print_list(nodes: CSSNode[], optional_space = SPACE): string {
parts.push(node.text)
}

if (!is_operator(node)) {
if (node.has_next) {
if (!is_operator(node.next_sibling)) {
parts.push(SPACE)
}
}
if (!is_operator(node) && node.has_next && !is_operator(node.next_sibling)) {
parts.push(SPACE)
}
}

Expand Down Expand Up @@ -305,22 +301,22 @@ export function format_atrule_prelude(
): string {
let optional_space = minify ? EMPTY_STRING : SPACE
return prelude
.replace(/\s*([:,])/g, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()`
.replace(/\)([a-zA-Z])/g, ') $1') // force whitespace between closing parenthesis and following text (usually and|or)
.replace(/\s*(=>|>=|<=)\s*/g, `${optional_space}$1${optional_space}`) // add optional spacing around =>, >= and <=
.replace(/([^<>=\s])([<>])([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // add spacing around < or > except when it's part of <=, >=, =>
.replace(/([^<>=\s])\s+([<>])\s+([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // handle spaces around < or > when they already have surrounding whitespace
.replace(/\s+/g, SPACE) // collapse multiple whitespaces into one
.replace(/([:,]) /g, minify ? '$1' : '$1 ') // in minify mode, remove optional spaces after : and ,
.replace(
.replaceAll(/\s*([:,])/g, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()`
.replaceAll(/\)([a-zA-Z])/g, ') $1') // force whitespace between closing parenthesis and following text (usually and|or)
.replaceAll(/\s*(=>|>=|<=)\s*/g, `${optional_space}$1${optional_space}`) // add optional spacing around =>, >= and <=
.replaceAll(/([^<>=\s])([<>])([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // add spacing around < or > except when it's part of <=, >=, =>
.replaceAll(/([^<>=\s])\s+([<>])\s+([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // handle spaces around < or > when they already have surrounding whitespace
.replaceAll(/\s+/g, SPACE) // collapse multiple whitespaces into one
.replaceAll(/([:,]) /g, minify ? '$1' : '$1 ') // in minify mode, remove optional spaces after : and ,
.replaceAll(
/calc\(\s*([^()+\-*/]+)\s*([*/+-])\s*([^()+\-*/]+)\s*\)/g,
(_, left, operator, right) => {
// force required or optional whitespace around * and / in calc()
let space = operator === '+' || operator === '-' ? SPACE : optional_space
return `calc(${left.trim()}${space}${operator}${space}${right.trim()})`
},
)
.replace(/selector|url|supports|layer\(/gi, (match) => match.toLowerCase()) // lowercase function names
.replaceAll(/selector|url|supports|layer\(/gi, (match) => match.toLowerCase()) // lowercase function names
}

/**
Expand Down Expand Up @@ -445,12 +441,12 @@ export function format(
let semi = is_last ? LAST_SEMICOLON : SEMICOLON
lines.push(indent(depth) + declaration + semi)
} else if (is_rule(child)) {
if (prev_end !== undefined && lines.length !== 0) {
if (prev_end !== undefined && lines.length > 0) {
lines.push(EMPTY_STRING)
}
lines.push(print_rule(child))
} else if (is_atrule(child)) {
if (prev_end !== undefined && lines.length !== 0) {
if (prev_end !== undefined && lines.length > 0) {
lines.push(EMPTY_STRING)
}
lines.push(indent(depth) + print_atrule(child))
Expand Down Expand Up @@ -504,13 +500,13 @@ export function format(

let block_has_content =
node.has_block && (!node.block.is_empty || !!get_comment(node.block.start, node.block.end))
if (!node.has_block) {
name += SEMICOLON
} else {
if (node.has_block) {
name += OPTIONAL_SPACE + OPEN_BRACE
if (!block_has_content) {
name += CLOSE_BRACE
}
} else {
name += SEMICOLON
}

if (block_has_content) {
Expand Down
1 change: 1 addition & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ describe('format_declaration', () => {

describe('format_value', () => {
test('null returns empty string', () => {
// oxlint-disable-next-line unicorn/no-null
expect(format_value(null)).toBe('')
})

Expand Down
1 change: 1 addition & 0 deletions test/atrules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ test('minify: keeps necessary whitespace between keywords', () => {
expect(actual).toEqual(expected)
})

// oxlint-disable-next-line vitest/no-disabled-tests
test.skip('preserves comments', () => {
let actual = format(`
@media /* comment */ all {}
Expand Down
6 changes: 4 additions & 2 deletions test/tab-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ test('tab_size: 2', () => {
})

test('invalid tab_size: 0', () => {
expect(() => format(fixture, { tab_size: 0 })).toThrow()
expect(() => format(fixture, { tab_size: 0 })).toThrow('tab_size must be a number greater than 0')
})

test('invalid tab_size: negative', () => {
expect(() => format(fixture, { tab_size: -1 })).toThrow()
expect(() => format(fixture, { tab_size: -1 })).toThrow(
'tab_size must be a number greater than 0',
)
})

test('combine tab_size and minify', () => {
Expand Down
Loading