Skip to content

feat!: add output formatter #124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 18, 2025
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
21 changes: 20 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Result:
Usage:
------

php-css-lint [--options='{ }'] input_to_lint
php-css-lint [--options='{ }'] [--formatter=name] [--formatter=name:path] input_to_lint

Arguments:
----------
Expand All @@ -40,6 +40,16 @@ Arguments:
* "nonStandards": { "property" => bool }: will merge with the current property
Example: --options='{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }'

--formatter
The formatter(s) to be used. Can be specified multiple times.
Format: --formatter=name (output to stdout) or --formatter=name:path (output to file)
If not specified, the default formatter will output to stdout.
Available formatters: plain, gitlab-ci, github-actions
Examples:
output to stdout: --formatter=plain
output to file: --formatter=plain:report.txt
multiple outputs: --formatter=plain --formatter=gitlab-ci:report.json

input_to_lint
The CSS file path (absolute or relative)
a glob pattern of file(s) to be linted
Expand All @@ -60,6 +70,15 @@ Examples:

Lint with only tabulation as indentation:
php-css-lint --options='{ "allowedIndentationChars": ["\t"] }' ".test { color: red; }"

Output to a file:
php-css-lint --formatter=plain:output.txt ".test { color: red; }"

Generate GitLab CI report:
php-css-lint --formatter=gitlab-ci:report.json "./path/to/css_file.css"

Multiple outputs (console and file):
php-css-lint --formatter=plain --formatter=gitlab-ci:ci-report.json ".test { color: red; }"
```

### Lint a file
Expand Down
160 changes: 94 additions & 66 deletions src/CssLint/Cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace CssLint;

use Generator;
use RuntimeException;
use Throwable;
use CssLint\Output\Formatter\FormatterFactory;
use CssLint\Output\Formatter\FormatterManager;
use Generator;

/**
* @phpstan-import-type Errors from \CssLint\Linter
Expand All @@ -21,6 +23,10 @@

private const RETURN_CODE_SUCCESS = 0;

private ?FormatterFactory $formatterFactory = null;

private FormatterManager $formatterManager;

/**
* Entrypoint of the cli, will execute the linter according to the given arguments
* @param string[] $arguments arguments to be parsed (@see $_SERVER['argv'])
Expand All @@ -29,6 +35,15 @@
public function run(array $arguments): int
{
$cliArgs = $this->parseArguments($arguments);

try {
$this->formatterManager = $this->getFormatterFactory()->create($cliArgs->formatters);
} catch (RuntimeException $error) {

Check warning on line 41 in src/CssLint/Cli.php

View check run for this annotation

Codecov / codecov/patch

src/CssLint/Cli.php#L41

Added line #L41 was not covered by tests
// report invalid formatter names via default (plain) formatter
$this->getFormatterFactory()->create()->printFatalError(null, $error);
return self::RETURN_CODE_ERROR;

Check warning on line 44 in src/CssLint/Cli.php

View check run for this annotation

Codecov / codecov/patch

src/CssLint/Cli.php#L43-L44

Added lines #L43 - L44 were not covered by tests
}

if ($cliArgs->input === null || $cliArgs->input === '' || $cliArgs->input === '0') {
$this->printUsage();
return self::RETURN_CODE_SUCCESS;
Expand All @@ -41,7 +56,7 @@

return $this->lintInput($cssLinter, $cliArgs->input);
} catch (Throwable $throwable) {
$this->printError($throwable->getMessage());
$this->formatterManager->printFatalError(null, $throwable);
return self::RETURN_CODE_ERROR;
}
}
Expand All @@ -51,43 +66,63 @@
*/
private function printUsage(): void
{
$this->printLine('Usage:' . PHP_EOL .
'------' . PHP_EOL .
PHP_EOL .
' ' . self::SCRIPT_NAME . " [--options='{ }'] input_to_lint" . PHP_EOL .
PHP_EOL .
'Arguments:' . PHP_EOL .
'----------' . PHP_EOL .
PHP_EOL .
' --options' . PHP_EOL .
' Options (optional), must be a json object:' . PHP_EOL .
' * "allowedIndentationChars" => [" "] or ["\t"]: will override the current property' . PHP_EOL .
' * "constructors": { "property" => bool }: will merge with the current property' . PHP_EOL .
' * "standards": { "property" => bool }: will merge with the current property' . PHP_EOL .
' * "nonStandards": { "property" => bool }: will merge with the current property' . PHP_EOL .
' Example: --options=\'{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }\'' .
PHP_EOL .
PHP_EOL .
' input_to_lint' . PHP_EOL .
' The CSS file path (absolute or relative)' . PHP_EOL .
' a glob pattern of file(s) to be linted' . PHP_EOL .
' or a CSS string to be linted' . PHP_EOL .
' Example:' . PHP_EOL .
' "./path/to/css_file_path_to_lint.css"' . PHP_EOL .
' "./path/to/css_file_path_to_lint/*.css"' . PHP_EOL .
' ".test { color: red; }"' . PHP_EOL .
PHP_EOL .
'Examples:' . PHP_EOL .
'---------' . PHP_EOL .
PHP_EOL .
' Lint a CSS file:' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' "./path/to/css_file_path_to_lint.css"' . PHP_EOL . PHP_EOL .
' Lint a CSS string:' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' ".test { color: red; }"' . PHP_EOL . PHP_EOL .
' Lint with only tabulation as indentation:' . PHP_EOL .
' ' . self::SCRIPT_NAME .
' --options=\'{ "allowedIndentationChars": ["\t"] }\' ".test { color: red; }"' . PHP_EOL .
PHP_EOL . PHP_EOL);
$availableFormatters = $this->getFormatterFactory()->getAvailableFormatters();
$defaultFormatter = $availableFormatters[0];

$this->printLine(
'Usage:' . PHP_EOL .
'------' . PHP_EOL .
PHP_EOL .
' ' . self::SCRIPT_NAME . " [--options='{ }'] [--formatter=name] [--formatter=name:path] input_to_lint" . PHP_EOL .
PHP_EOL .
'Arguments:' . PHP_EOL .
'----------' . PHP_EOL .
PHP_EOL .
' --options' . PHP_EOL .
' Options (optional), must be a json object:' . PHP_EOL .
' * "allowedIndentationChars" => [" "] or ["\t"]: will override the current property' . PHP_EOL .
' * "constructors": { "property" => bool }: will merge with the current property' . PHP_EOL .
' * "standards": { "property" => bool }: will merge with the current property' . PHP_EOL .
' * "nonStandards": { "property" => bool }: will merge with the current property' . PHP_EOL .
' Example: --options=\'{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }\'' .
PHP_EOL .
PHP_EOL .
' --formatter' . PHP_EOL .
' The formatter(s) to be used. Can be specified multiple times.' . PHP_EOL .
' Format: --formatter=name (output to stdout) or --formatter=name:path (output to file)' . PHP_EOL .
' If not specified, the default formatter will output to stdout.' . PHP_EOL .
' Available formatters: ' . implode(', ', $availableFormatters) . PHP_EOL .
' Examples:' . PHP_EOL .
' output to stdout: --formatter=' . $defaultFormatter . PHP_EOL .
' output to file: --formatter=' . $defaultFormatter . ':report.txt' . PHP_EOL .
' multiple outputs: --formatter=' . $defaultFormatter . ' --formatter=' . $availableFormatters[1] . ':report.json' . PHP_EOL .
PHP_EOL .
' input_to_lint' . PHP_EOL .
' The CSS file path (absolute or relative)' . PHP_EOL .
' a glob pattern of file(s) to be linted' . PHP_EOL .
' or a CSS string to be linted' . PHP_EOL .
' Example:' . PHP_EOL .
' "./path/to/css_file_path_to_lint.css"' . PHP_EOL .
' "./path/to/css_file_path_to_lint/*.css"' . PHP_EOL .
' ".test { color: red; }"' . PHP_EOL .
PHP_EOL .
'Examples:' . PHP_EOL .
'---------' . PHP_EOL .
PHP_EOL .
' Lint a CSS file:' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' "./path/to/css_file_path_to_lint.css"' . PHP_EOL . PHP_EOL .
' Lint a CSS string:' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' ".test { color: red; }"' . PHP_EOL . PHP_EOL .
' Lint with only tabulation as indentation:' . PHP_EOL .
' ' . self::SCRIPT_NAME .
' --options=\'{ "allowedIndentationChars": ["\t"] }\' ".test { color: red; }"' . PHP_EOL . PHP_EOL .
' Output to a file:' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' --formatter=plain:output.txt ".test { color: red; }"' . PHP_EOL . PHP_EOL .
' Generate GitLab CI report:' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' --formatter=gitlab-ci:report.json "./path/to/css_file.css"' . PHP_EOL . PHP_EOL .
' Multiple outputs (console and file):' . PHP_EOL .
' ' . self::SCRIPT_NAME . ' --formatter=plain --formatter=gitlab-ci:ci-report.json ".test { color: red; }"' . PHP_EOL . PHP_EOL
);
}

/**
Expand All @@ -100,6 +135,15 @@
return new CliArgs($arguments);
}

private function getFormatterFactory(): FormatterFactory
{
if ($this->formatterFactory === null) {
$this->formatterFactory = new FormatterFactory();
}

return $this->formatterFactory;
}

/**
* Retrieve the properties from the given options
* @param string $options the options to be parsed
Expand Down Expand Up @@ -207,7 +251,7 @@
$cssLinter = new Linter();
$files = glob($glob);
if ($files === [] || $files === false) {
$this->printError('No files found for glob "' . $glob . '"');
$this->formatterManager->printFatalError($glob, 'No files found for given glob pattern');
return self::RETURN_CODE_ERROR;
}

Expand All @@ -227,19 +271,17 @@
*/
private function lintFile(Linter $cssLinter, string $filePath): int
{
$source = "CSS file \"" . $filePath . "\"";
$this->printLine('# Lint ' . $source . '...');

$source = "CSS file \"{$filePath}\"";
$this->formatterManager->startLinting($source);
if (!is_readable($filePath)) {
$this->printError('File "' . $filePath . '" is not readable');
$this->formatterManager->printFatalError($source, 'File is not readable');

Check warning on line 277 in src/CssLint/Cli.php

View check run for this annotation

Codecov / codecov/patch

src/CssLint/Cli.php#L277

Added line #L277 was not covered by tests
return self::RETURN_CODE_ERROR;
}

$errors = $cssLinter->lintFile($filePath);
return $this->printLinterErrors($source, $errors);
}


/**
* Performs lint on a given string
* @param Linter $cssLinter the instance of the linter
Expand All @@ -249,43 +291,29 @@
private function lintString(Linter $cssLinter, string $stringValue): int
{
$source = 'CSS string';
$this->printLine('# Lint ' . $source . '...');
$this->formatterManager->startLinting($source);
$errors = $cssLinter->lintString($stringValue);
return $this->printLinterErrors($source, $errors);
}

/**
* Display an error message
* @param string $error the message to be displayed
*/
private function printError(string $error): void
{
$this->printLine("\033[31m/!\ Error: " . $error . "\033[0m" . PHP_EOL);
}

/**
* Display the errors returned by the linter
* @param Generator<LintError> $errors the generated errors to be displayed
* @return int the return code related to the execution of the linter
*/
private function printLinterErrors(string $source, Generator $errors): int
{
$hasErrors = false;
$isValid = true;
foreach ($errors as $error) {
if ($hasErrors === false) {
$this->printLine("\033[31m => " . $source . " is not valid:\033[0m" . PHP_EOL);
$hasErrors = true;
if ($isValid === true) {
$isValid = false;
}
$this->printLine("\033[31m - " . $error . "\033[0m");
$this->formatterManager->printLintError($source, $error);
}

if ($hasErrors) {
$this->printLine("");
return self::RETURN_CODE_ERROR;
}
$this->formatterManager->endLinting($source, $isValid);

$this->printLine("\033[32m => " . $source . " is valid\033[0m" . PHP_EOL);
return self::RETURN_CODE_SUCCESS;
return $isValid ? self::RETURN_CODE_SUCCESS : self::RETURN_CODE_ERROR;
}

/**
Expand Down
45 changes: 32 additions & 13 deletions src/CssLint/CliArgs.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@
/**
* @package CssLint
* @phpstan-type Arguments string[]
* @phpstan-type ParsedArguments array<string, string>
*/
class CliArgs
{
public ?string $input = null;

public ?string $options = null;

/**
* Array of formatter specifications with their output destinations
* Format: ['plain' => null, 'gitlab-ci' => '/path/to/report.json']
* @var array<string, string|null>
*/
public array $formatters = [];

/**
* Constructor
* @param Arguments $arguments arguments to be parsed (@see $_SERVER['argv'])
Expand All @@ -32,36 +38,49 @@ public function __construct(array $arguments)
$this->input = array_pop($arguments);

if ($arguments !== []) {
$parsedArguments = $this->parseArguments($arguments);

if (!empty($parsedArguments['options'])) {
$this->options = $parsedArguments['options'];
}
$this->parseArguments($arguments);
}
}

/**
* @param Arguments $arguments array of arguments to be parsed (@see $_SERVER['argv'])
* @return ParsedArguments an associative array of key=>value arguments
*/
private function parseArguments(array $arguments): array
private function parseArguments(array $arguments): void
{
$aParsedArguments = [];

foreach ($arguments as $argument) {
// --foo --bar=baz
if (str_starts_with((string) $argument, '--')) {
$equalPosition = strpos((string) $argument, '=');

// --bar=baz
if ($equalPosition !== false) {
$key = substr((string) $argument, 2, $equalPosition - 2);
$value = substr((string) $argument, $equalPosition + 1);
$aParsedArguments[$key] = $value;

if ($key === 'options') {
$this->options = $value;
} elseif ($key === 'formatter') {
$this->parseFormatterSpec($value);
}
}
}
}
}

/**
* Parse a formatter specification like "plain" or "gitlab-ci:/path/to/file.json"
*/
private function parseFormatterSpec(string $formatterSpec): void
{
$colonPosition = strpos($formatterSpec, ':');

return $aParsedArguments;
if ($colonPosition !== false) {
// Format: formatter:path
$formatterName = substr($formatterSpec, 0, $colonPosition);
$outputPath = substr($formatterSpec, $colonPosition + 1);
$this->formatters[$formatterName] = $outputPath;
} else {
// Format: formatter (stdout only)
$this->formatters[$formatterSpec] = null;
}
}
}
Loading