From f5bf042dcdc50f22b0875986ab819b0bde936aa9 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Tue, 17 Jun 2025 20:26:01 +0200 Subject: [PATCH 1/4] feat: add output formatter Signed-off-by: Emilien Escalle --- docs/usage.md | 9 +- src/CssLint/Cli.php | 78 ++++++++++------ src/CssLint/CliArgs.php | 8 ++ src/CssLint/Formatter/FormatterFactory.php | 72 +++++++++++++++ src/CssLint/Formatter/FormatterInterface.php | 50 ++++++++++ src/CssLint/Formatter/FormatterManager.php | 52 +++++++++++ src/CssLint/Formatter/PlainFormatter.php | 51 ++++++++++ tests/TestSuite/CliTest.php | 28 ++---- .../Formatter/FormatterFactoryTest.php | 28 ++++++ .../Formatter/FormatterManagerTest.php | 92 +++++++++++++++++++ .../Formatter/PlainFormatterTest.php | 77 ++++++++++++++++ 11 files changed, 495 insertions(+), 50 deletions(-) create mode 100644 src/CssLint/Formatter/FormatterFactory.php create mode 100644 src/CssLint/Formatter/FormatterInterface.php create mode 100644 src/CssLint/Formatter/FormatterManager.php create mode 100644 src/CssLint/Formatter/PlainFormatter.php create mode 100644 tests/TestSuite/Formatter/FormatterFactoryTest.php create mode 100644 tests/TestSuite/Formatter/FormatterManagerTest.php create mode 100644 tests/TestSuite/Formatter/PlainFormatterTest.php diff --git a/docs/usage.md b/docs/usage.md index 85b06f4..624ff62 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,7 +27,7 @@ Result: Usage: ------ - php-css-lint [--options='{ }'] input_to_lint + php-css-lint [--options='{ }'] [--formatter=plain|json] input_to_lint Arguments: ---------- @@ -40,6 +40,13 @@ Arguments: * "nonStandards": { "property" => bool }: will merge with the current property Example: --options='{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }' + --formatter + The formatter(s) to be used + If not specified, the first available formatter will be used. + Multiple formatters can be specified as a comma-separated list. + Available formatters: plain, json + Example: --formatter=plain + input_to_lint The CSS file path (absolute or relative) a glob pattern of file(s) to be linted diff --git a/src/CssLint/Cli.php b/src/CssLint/Cli.php index 326d7fd..7d52318 100644 --- a/src/CssLint/Cli.php +++ b/src/CssLint/Cli.php @@ -4,9 +4,11 @@ namespace CssLint; -use Generator; use RuntimeException; use Throwable; +use CssLint\Formatter\FormatterInterface; +use CssLint\Formatter\FormatterFactory; +use Generator; /** * @phpstan-import-type Errors from \CssLint\Linter @@ -21,6 +23,10 @@ class Cli private const RETURN_CODE_SUCCESS = 0; + private ?FormatterFactory $formatterFactory = null; + + private FormatterInterface $formatterManager; + /** * Entrypoint of the cli, will execute the linter according to the given arguments * @param string[] $arguments arguments to be parsed (@see $_SERVER['argv']) @@ -29,6 +35,15 @@ class Cli public function run(array $arguments): int { $cliArgs = $this->parseArguments($arguments); + + try { + $this->formatterManager = $this->getFormatterFactory()->create($cliArgs->formatter); + } catch (RuntimeException $error) { + // report invalid formatter names via default (plain) formatter + $this->getFormatterFactory()->create(null)->printFatalError(null, $error); + return self::RETURN_CODE_ERROR; + } + if ($cliArgs->input === null || $cliArgs->input === '' || $cliArgs->input === '0') { $this->printUsage(); return self::RETURN_CODE_SUCCESS; @@ -41,7 +56,7 @@ public function run(array $arguments): int return $this->lintInput($cssLinter, $cliArgs->input); } catch (Throwable $throwable) { - $this->printError($throwable->getMessage()); + $this->formatterManager->printFatalError(null, $throwable); return self::RETURN_CODE_ERROR; } } @@ -51,10 +66,13 @@ public function run(array $arguments): int */ private function printUsage(): void { + $availableFormatters = $this->getFormatterFactory()->getAvailableFormatters(); + $defaultFormatter = $availableFormatters[0]; + $this->printLine('Usage:' . PHP_EOL . '------' . PHP_EOL . PHP_EOL . - ' ' . self::SCRIPT_NAME . " [--options='{ }'] input_to_lint" . PHP_EOL . + ' ' . self::SCRIPT_NAME . " [--options='{ }'] [--formatter=plain|json] input_to_lint" . PHP_EOL . PHP_EOL . 'Arguments:' . PHP_EOL . '----------' . PHP_EOL . @@ -68,6 +86,13 @@ private function printUsage(): void ' Example: --options=\'{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }\'' . PHP_EOL . PHP_EOL . + ' --formatter' . PHP_EOL . + ' The formatter(s) to be used' . PHP_EOL . + ' If not specified, the first available formatter will be used.' . PHP_EOL . + ' Multiple formatters can be specified as a comma-separated list.' . PHP_EOL . + ' Available formatters: ' . implode(', ', $availableFormatters) . PHP_EOL . + ' Example: --formatter=' . $defaultFormatter . 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 . @@ -100,6 +125,15 @@ private function parseArguments(array $arguments): CliArgs 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 @@ -207,7 +241,7 @@ private function lintGlob(string $glob): int $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; } @@ -227,11 +261,10 @@ private function lintGlob(string $glob): int */ 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'); return self::RETURN_CODE_ERROR; } @@ -239,7 +272,6 @@ private function lintFile(Linter $cssLinter, string $filePath): int return $this->printLinterErrors($source, $errors); } - /** * Performs lint on a given string * @param Linter $cssLinter the instance of the linter @@ -249,20 +281,11 @@ private function lintFile(Linter $cssLinter, string $filePath): int 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 $errors the generated errors to be displayed @@ -270,22 +293,17 @@ private function printError(string $error): void */ 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; } /** diff --git a/src/CssLint/CliArgs.php b/src/CssLint/CliArgs.php index ed3202e..38dc7c8 100644 --- a/src/CssLint/CliArgs.php +++ b/src/CssLint/CliArgs.php @@ -15,6 +15,11 @@ class CliArgs public ?string $options = null; + /** + * Output formatter type + */ + public ?string $formatter = null; + /** * Constructor * @param Arguments $arguments arguments to be parsed (@see $_SERVER['argv']) @@ -37,6 +42,9 @@ public function __construct(array $arguments) if (!empty($parsedArguments['options'])) { $this->options = $parsedArguments['options']; } + if (!empty($parsedArguments['formatter'])) { + $this->formatter = $parsedArguments['formatter']; + } } } diff --git a/src/CssLint/Formatter/FormatterFactory.php b/src/CssLint/Formatter/FormatterFactory.php new file mode 100644 index 0000000..4e5eea9 --- /dev/null +++ b/src/CssLint/Formatter/FormatterFactory.php @@ -0,0 +1,72 @@ + */ + private array $available; + + public function __construct() + { + $availableFormatters = [new PlainFormatter()]; + foreach ($availableFormatters as $formatter) { + $this->available[$formatter->getName()] = $formatter; + } + } + + /** + * Create a FormatterManager based on a comma-separated list of formatter names. + * @param string|null $formatterArg e.g. 'plain,json' + * @return FormatterManager + * @throws RuntimeException on invalid formatter names + */ + public function create(?string $formatterArg): FormatterManager + { + $names = array_filter(array_map('trim', explode(',', (string) $formatterArg))); + $instances = []; + $invalid = []; + + $available = $this->getAvailableFormatters(); + + foreach ($names as $name) { + if (in_array($name, $available, true)) { + $instances[] = $this->available[$name]; + } else { + $invalid[] = $name; + } + } + + if (!empty($invalid)) { + throw new RuntimeException('Invalid formatter(s): ' . implode(', ', $invalid)); + } + + if (empty($instances)) { + // Return the first available formatter if none specified + // If no formatters are available, throw an exception + if (empty($this->available)) { + throw new RuntimeException('No formatters available'); + } + + $instances[] = $this->available[array_key_first($this->available)]; + } + + return new FormatterManager($instances); + } + + /** + * Get the names of all available formatters. + * @return non-empty-string[] List of formatter names + */ + public function getAvailableFormatters(): array + { + return array_keys($this->available); + } +} diff --git a/src/CssLint/Formatter/FormatterInterface.php b/src/CssLint/Formatter/FormatterInterface.php new file mode 100644 index 0000000..cb5a091 --- /dev/null +++ b/src/CssLint/Formatter/FormatterInterface.php @@ -0,0 +1,50 @@ +formatters as $formatter) { + $formatter->startLinting($source); + } + } + + public function printFatalError(?string $source, mixed $error): void + { + foreach ($this->formatters as $formatter) { + $formatter->printFatalError($source, $error); + } + } + + public function printLintError(string $source, mixed $error): void + { + foreach ($this->formatters as $formatter) { + $formatter->printLintError($source, $error); + } + } + + public function endLinting(string $source, bool $isValid): void + { + foreach ($this->formatters as $formatter) { + $formatter->endLinting($source, $isValid); + } + } + + public function getName(): string + { + throw new RuntimeException('FormatterManager does not have a single name. Use the names of individual formatters instead.'); + } +} diff --git a/src/CssLint/Formatter/PlainFormatter.php b/src/CssLint/Formatter/PlainFormatter.php new file mode 100644 index 0000000..cd74045 --- /dev/null +++ b/src/CssLint/Formatter/PlainFormatter.php @@ -0,0 +1,51 @@ +getMessage(); + } + + if ($source) { + $error = "$source - " . $error; + } + + echo "\033[31m/!\ Error: " . $error . "\033[0m" . PHP_EOL; + } + + public function printLintError(string $source, mixed $error): void + { + echo "\033[31m - " . $error . "\033[0m" . PHP_EOL; + } + + public function endLinting(string $source, bool $isValid): void + { + if ($isValid) { + echo "\033[32m => Success: {$source} is valid.\033[0m" . PHP_EOL . PHP_EOL; + } else { + echo "\033[31m => Failure: {$source} is invalid CSS.\033[0m" . PHP_EOL; + } + } + + public function getName(): string + { + return 'plain'; + } +} diff --git a/tests/TestSuite/CliTest.php b/tests/TestSuite/CliTest.php index 999fb88..aa0725c 100644 --- a/tests/TestSuite/CliTest.php +++ b/tests/TestSuite/CliTest.php @@ -33,7 +33,7 @@ public function testRunWithValidStringShouldReturnSuccessCode() { $this->expectOutputString( '# Lint CSS string...' . PHP_EOL . - "\033[32m => CSS string is valid\033[0m" . PHP_EOL . + "\033[32m => Success: CSS string is valid.\033[0m" . PHP_EOL . PHP_EOL ); $this->assertEquals( @@ -47,11 +47,9 @@ public function testRunWithNotValidStringShouldReturnErrorCode() { $this->expectOutputString( '# Lint CSS string...' . PHP_EOL . - "\033[31m => CSS string is not valid:\033[0m" . PHP_EOL . - PHP_EOL . "\033[31m - [unexpected_character_in_block_content]: block - Unexpected character: \":\" (line 1, column 6 to line 3, column 16)\033[0m" . PHP_EOL . "\033[31m - [invalid_property_declaration]: property - Unknown property \"displady\" (line 1, column 7 to line 1, column 23)\033[0m" . PHP_EOL . - PHP_EOL + "\033[31m => Failure: CSS string is invalid CSS.\033[0m" . PHP_EOL ); $this->assertEquals(1, $this->cli->run([ @@ -68,11 +66,9 @@ public function testRunWithNotValidFileShouldReturnErrorCode() $this->expectOutputString( "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . - "\033[31m => CSS file \"$fileToLint\" is not valid:\033[0m" . PHP_EOL . - PHP_EOL . "\033[31m - [invalid_property_declaration]: property - Unknown property \"bordr-top-style\" (line 3, column 5 to line 3, column 27)\033[0m" . PHP_EOL . "\033[31m - [unclosed_token]: block - Unclosed \"block\" detected (line 1, column 23 to line 6, column 2)\033[0m" . PHP_EOL . - PHP_EOL + "\033[31m => Failure: CSS file \"$fileToLint\" is invalid CSS.\033[0m" . PHP_EOL ); $this->assertEquals(1, $this->cli->run(['php-css-lint', $fileToLint])); } @@ -82,7 +78,7 @@ public function testRunWithGlobShouldReturnSuccessCode() $fileToLint = $this->testFixturesDir . '/valid.css'; $this->expectOutputString( "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . - "\033[32m => CSS file \"$fileToLint\" is valid\033[0m" . PHP_EOL . + "\033[32m => Success: CSS file \"$fileToLint\" is valid.\033[0m" . PHP_EOL . PHP_EOL ); $this->assertEquals(0, $this->cli->run(['php-css-lint', $this->testFixturesDir . '/valid*.css']), $this->getActualOutput()); @@ -93,8 +89,7 @@ public function testRunWithNoFilesGlobShouldReturnErrorCode() $filesToLint = $this->testFixturesDir . '/unknown*.css'; $this->expectOutputString( - "\033[31m/!\ Error: No files found for glob \"$filesToLint\"\033[0m" . PHP_EOL . - PHP_EOL + "\033[31m/!\ Error: $filesToLint - No files found for given glob pattern\033[0m" . PHP_EOL ); $this->assertEquals(1, $this->cli->run(['php-css-lint', $filesToLint])); } @@ -104,11 +99,9 @@ public function testRunWithNotValidFileGlobShouldReturnErrorCode() $fileToLint = $this->testFixturesDir . '/not_valid.css'; $this->expectOutputString( "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . - "\033[31m => CSS file \"$fileToLint\" is not valid:\033[0m" . PHP_EOL . - PHP_EOL . "\033[31m - [invalid_property_declaration]: property - Unknown property \"bordr-top-style\" (line 3, column 5 to line 3, column 27)\033[0m" . PHP_EOL . "\033[31m - [unclosed_token]: block - Unclosed \"block\" detected (line 1, column 23 to line 6, column 2)\033[0m" . PHP_EOL . - PHP_EOL + "\033[31m => Failure: CSS file \"$fileToLint\" is invalid CSS.\033[0m" . PHP_EOL ); $this->assertEquals(1, $this->cli->run(['php-css-lint', $this->testFixturesDir . '/not_valid*.css'])); } @@ -117,10 +110,8 @@ public function testRunWithOptionsMustBeUsedByTheLinter() { $this->expectOutputString( "# Lint CSS string..." . PHP_EOL . - "\033[31m => CSS string is not valid:\033[0m" . PHP_EOL . - PHP_EOL . "\033[31m - [invalid_indentation_character]: whitespace - Unexpected char \" \" (line 2, column 1 to line 2, column 2)\033[0m" . PHP_EOL . - PHP_EOL + "\033[31m => Failure: CSS string is invalid CSS.\033[0m" . PHP_EOL ); $this->assertEquals(1, $this->cli->run([ @@ -148,8 +139,7 @@ public function unvalidOptionsProvider() public function testRunWithInvalidOptionsFormatShouldReturnAnError(string $options, string $expectedOutput) { $this->expectOutputString( - "\033[31m/!\ Error: $expectedOutput\033[0m" . PHP_EOL . - PHP_EOL + "\033[31m/!\ Error: $expectedOutput\033[0m" . PHP_EOL ); $this->assertEquals(1, $this->cli->run([ @@ -176,7 +166,7 @@ public function testRunWithValidFileShouldReturnSuccessCode(string $fileToLint) $fileToLint = $this->testFixturesDir . '/' . $fileToLint; $this->expectOutputString( "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . - "\033[32m => CSS file \"$fileToLint\" is valid\033[0m" . PHP_EOL . + "\033[32m => Success: CSS file \"$fileToLint\" is valid.\033[0m" . PHP_EOL . PHP_EOL ); $this->assertEquals(0, $this->cli->run(['php-css-lint', $fileToLint]), $this->getActualOutput()); diff --git a/tests/TestSuite/Formatter/FormatterFactoryTest.php b/tests/TestSuite/Formatter/FormatterFactoryTest.php new file mode 100644 index 0000000..db6096a --- /dev/null +++ b/tests/TestSuite/Formatter/FormatterFactoryTest.php @@ -0,0 +1,28 @@ +create(null); + $this->assertInstanceOf(FormatterManager::class, $manager); + } + + public function testCreateWithInvalidNameThrowsException(): void + { + $factory = new FormatterFactory(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid formatter(s): invalid'); + $factory->create('invalid'); + } +} diff --git a/tests/TestSuite/Formatter/FormatterManagerTest.php b/tests/TestSuite/Formatter/FormatterManagerTest.php new file mode 100644 index 0000000..1e2e60e --- /dev/null +++ b/tests/TestSuite/Formatter/FormatterManagerTest.php @@ -0,0 +1,92 @@ +createMock(FormatterInterface::class); + $formatter1->expects($this->once()) + ->method('startLinting') + ->with('source.css'); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('startLinting') + ->with('source.css'); + + $manager = new FormatterManager([$formatter1, $formatter2]); + $manager->startLinting('source.css'); + } + + public function testPrintFatalErrorPropagatesToAllFormatters(): void + { + $error = new Exception('fatal error'); + + $formatter1 = $this->createMock(FormatterInterface::class); + $formatter1->expects($this->once()) + ->method('printFatalError') + ->with('file.css', $error); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('printFatalError') + ->with('file.css', $error); + + $manager = new FormatterManager([$formatter1, $formatter2]); + $manager->printFatalError('file.css', $error); + } + + public function testPrintLintErrorPropagatesToAllFormatters(): void + { + $lintError = $this->createMock(LintError::class); + + $formatter1 = $this->createMock(FormatterInterface::class); + $formatter1->expects($this->once()) + ->method('printLintError') + ->with('file.css', $lintError); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('printLintError') + ->with('file.css', $lintError); + + $manager = new FormatterManager([$formatter1, $formatter2]); + $manager->printLintError('file.css', $lintError); + } + + public function testEndLintingPropagatesToAllFormatters(): void + { + $formatter1 = $this->createMock(FormatterInterface::class); + $formatter1->expects($this->once()) + ->method('endLinting') + ->with('file.css', true); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('endLinting') + ->with('file.css', true); + + $manager = new FormatterManager([$formatter1, $formatter2]); + $manager->endLinting('file.css', true); + } + + public function testGetNameThrowsRuntimeException(): void + { + $formatter = $this->createMock(FormatterInterface::class); + + $manager = new FormatterManager([$formatter]); + $this->expectException(RuntimeException::class); + $manager->getName(); + } +} diff --git a/tests/TestSuite/Formatter/PlainFormatterTest.php b/tests/TestSuite/Formatter/PlainFormatterTest.php new file mode 100644 index 0000000..2aa7586 --- /dev/null +++ b/tests/TestSuite/Formatter/PlainFormatterTest.php @@ -0,0 +1,77 @@ +assertSame('plain', $formatter->getName()); + } + + public function testStartLintingOutputsCorrectMessage(): void + { + $formatter = new PlainFormatter(); + $this->expectOutputString("# Lint file.css..." . PHP_EOL); + $formatter->startLinting('file.css'); + } + + public function testPrintFatalErrorWithThrowableOutputsColoredMessage(): void + { + $formatter = new PlainFormatter(); + $error = new Exception('fatal error'); + $this->expectOutputString( + "\033[31m/!\ Error: file.css - fatal error\033[0m" . PHP_EOL + ); + $formatter->printFatalError('file.css', $error); + } + + public function testPrintFatalErrorWithStringOutputsColoredMessage(): void + { + $formatter = new PlainFormatter(); + $message = 'some error'; + $this->expectOutputString( + "\033[31m/!\ Error: file.css - some error\033[0m" . PHP_EOL + ); + $formatter->printFatalError('file.css', $message); + } + + public function testPrintLintErrorOutputsColoredMessage(): void + { + $formatter = new PlainFormatter(); + // Using a LintError stub to provide a string representation + $error = $this->createStub(LintError::class); + $error->method('__toString')->willReturn('lint issue'); + + $this->expectOutputString( + "\033[31m - lint issue\033[0m" . PHP_EOL + ); + $formatter->printLintError('file.css', $error); + } + + public function testEndLintingOutputsSuccessWhenValid(): void + { + $formatter = new PlainFormatter(); + $this->expectOutputString( + "\033[32m => Success: file.css is valid.\033[0m" . PHP_EOL . PHP_EOL + ); + $formatter->endLinting('file.css', true); + } + + public function testEndLintingOutputsFailureWhenInvalid(): void + { + $formatter = new PlainFormatter(); + $this->expectOutputString( + "\033[31m => Failure: file.css is invalid CSS.\033[0m" . PHP_EOL + ); + $formatter->endLinting('file.css', false); + } +} From 7c707bc6c62001e58d02db339ddc03a12db31daf Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Tue, 17 Jun 2025 21:02:04 +0200 Subject: [PATCH 2/4] feat(formatter): add Github Actions formatter Signed-off-by: Emilien Escalle --- src/CssLint/Formatter/FormatterFactory.php | 3 +- .../Formatter/GithubActionsFormatter.php | 54 +++++++++++++ src/CssLint/Formatter/PlainFormatter.php | 5 +- src/CssLint/LintError.php | 40 ++++++++++ tests/TestSuite/CliTest.php | 12 ++- .../Formatter/GithubActionsFormatterTest.php | 80 +++++++++++++++++++ 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 src/CssLint/Formatter/GithubActionsFormatter.php create mode 100644 tests/TestSuite/Formatter/GithubActionsFormatterTest.php diff --git a/src/CssLint/Formatter/FormatterFactory.php b/src/CssLint/Formatter/FormatterFactory.php index 4e5eea9..979c8ec 100644 --- a/src/CssLint/Formatter/FormatterFactory.php +++ b/src/CssLint/Formatter/FormatterFactory.php @@ -5,6 +5,7 @@ namespace CssLint\Formatter; use RuntimeException; +use CssLint\Formatter\GithubActionsFormatter; /** * Factory to create FormatterManager based on requested names. @@ -16,7 +17,7 @@ class FormatterFactory public function __construct() { - $availableFormatters = [new PlainFormatter()]; + $availableFormatters = [new PlainFormatter(), new GithubActionsFormatter()]; foreach ($availableFormatters as $formatter) { $this->available[$formatter->getName()] = $formatter; } diff --git a/src/CssLint/Formatter/GithubActionsFormatter.php b/src/CssLint/Formatter/GithubActionsFormatter.php new file mode 100644 index 0000000..6904405 --- /dev/null +++ b/src/CssLint/Formatter/GithubActionsFormatter.php @@ -0,0 +1,54 @@ +getMessage() : (string) $error; + $location = ''; + if ($source) { + $location = "file={$source}"; + } + echo "::error {$location}::{$message}" . PHP_EOL; + } + + public function printLintError(string $source, LintError $lintError): void + { + $key = $lintError->getKey(); + $message = $lintError->getMessage(); + $startPosition = $lintError->getStart(); + $line = $startPosition->getLine(); + $col = $startPosition->getColumn(); + echo "::error file={$source},line={$line},col={$col}::{$key->value} - {$message}" . PHP_EOL; + } + + public function endLinting(string $source, bool $isValid): void + { + if ($isValid) { + echo "::notice ::Success: {$source} is valid." . PHP_EOL; + } else { + echo "::error file={$source}::{$source} is invalid CSS." . PHP_EOL; + } + echo "::endgroup::" . PHP_EOL; + } +} diff --git a/src/CssLint/Formatter/PlainFormatter.php b/src/CssLint/Formatter/PlainFormatter.php index cd74045..0ae4496 100644 --- a/src/CssLint/Formatter/PlainFormatter.php +++ b/src/CssLint/Formatter/PlainFormatter.php @@ -4,6 +4,7 @@ namespace CssLint\Formatter; +use CssLint\LintError; use Generator; use Throwable; @@ -30,9 +31,9 @@ public function printFatalError(?string $source, mixed $error): void echo "\033[31m/!\ Error: " . $error . "\033[0m" . PHP_EOL; } - public function printLintError(string $source, mixed $error): void + public function printLintError(string $source, LintError $lintError): void { - echo "\033[31m - " . $error . "\033[0m" . PHP_EOL; + echo "\033[31m - " . $lintError . "\033[0m" . PHP_EOL; } public function endLinting(string $source, bool $isValid): void diff --git a/src/CssLint/LintError.php b/src/CssLint/LintError.php index dabf049..dbebdc5 100644 --- a/src/CssLint/LintError.php +++ b/src/CssLint/LintError.php @@ -74,4 +74,44 @@ public function jsonSerialize(): array 'end' => $this->end->jsonSerialize(), ]; } + + /** + * Get the key of the lint error. + * + * @return LintErrorKey + */ + public function getKey(): LintErrorKey + { + return $this->key; + } + + /** + * Get the message of the lint error. + * + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Get the start position of the lint error. + * + * @return Position + */ + public function getStart(): Position + { + return $this->start; + } + + /** + * Get the end position of the lint error. + * + * @return Position + */ + public function getEnd(): Position + { + return $this->end; + } } diff --git a/tests/TestSuite/CliTest.php b/tests/TestSuite/CliTest.php index aa0725c..e350dac 100644 --- a/tests/TestSuite/CliTest.php +++ b/tests/TestSuite/CliTest.php @@ -129,7 +129,6 @@ public function unvalidOptionsProvider() 'non array options' => ['true', 'Unable to parse option argument: must be a json object'], 'not allowed option' => ['{ "unknownOption": true }', 'Invalid option key: "unknownOption"'], 'invalid option "allowedIndentationChars" value' => ['{ "allowedIndentationChars": "invalid" }', 'Option "allowedIndentationChars" must be an array'], - ]; } @@ -149,6 +148,17 @@ public function testRunWithInvalidOptionsFormatShouldReturnAnError(string $optio ])); } + public function testRunWithFormatterArgumentShouldReturnSuccessCode() + { + $fileToLint = $this->testFixturesDir . '/valid.css'; + $this->expectOutputString( + "::group::Lint CSS file \"$fileToLint\"" . PHP_EOL . + "::notice ::Success: CSS file \"$fileToLint\" is valid." . PHP_EOL . + "::endgroup::" . PHP_EOL + ); + $this->assertEquals(0, $this->cli->run(['php-css-lint', '--formatter=github-actions', $fileToLint]), $this->getActualOutput()); + } + public function validCssFilesProvider(): array { return [ diff --git a/tests/TestSuite/Formatter/GithubActionsFormatterTest.php b/tests/TestSuite/Formatter/GithubActionsFormatterTest.php new file mode 100644 index 0000000..3330b62 --- /dev/null +++ b/tests/TestSuite/Formatter/GithubActionsFormatterTest.php @@ -0,0 +1,80 @@ +assertSame('github-actions', $formatter->getName()); + } + + public function testStartLintingOutputsGroup(): void + { + $formatter = new GithubActionsFormatter(); + $this->expectOutputString("::group::Lint file.css" . PHP_EOL); + $formatter->startLinting('file.css'); + } + + public function testPrintFatalErrorWithThrowable(): void + { + $formatter = new GithubActionsFormatter(); + $error = new Exception('fatal error'); + $this->expectOutputString("::error file=file.css::fatal error" . PHP_EOL); + $formatter->printFatalError('file.css', $error); + } + + public function testPrintFatalErrorWithoutSource(): void + { + $formatter = new GithubActionsFormatter(); + $this->expectOutputString("::error ::some error" . PHP_EOL); + $formatter->printFatalError(null, 'some error'); + } + + public function testPrintLintError(): void + { + $positionArr = ['line' => 10, 'column' => 5]; + $lintError = new LintError( + key: LintErrorKey::INVALID_AT_RULE_DECLARATION, + message: 'issue found', + start: new Position($positionArr['line'], $positionArr['column']), + end: new Position($positionArr['line'], $positionArr['column']) + ); + + $formatter = new GithubActionsFormatter(); + $this->expectOutputString("::error file=file.css,line=10,col=5::issue found" . PHP_EOL); + $formatter->printLintError('file.css', $lintError); + } + + public function testEndLintingOutputsEndGroup(): void + { + $formatter = new GithubActionsFormatter(); + $this->expectOutputString( + "::notice ::Success: file.css is valid." . PHP_EOL . + "::endgroup::" . PHP_EOL + ); + $formatter->endLinting('file.css', true); + } + + public function testFactoryIntegration(): void + { + $factory = new FormatterFactory(); + $available = $factory->getAvailableFormatters(); + $this->assertContains('github-actions', $available); + + $manager = $factory->create('github-actions'); + $this->assertInstanceOf(FormatterManager::class, $manager); + } +} From bf955e7526fc5fe2ab24b2decd1bfa5d5101968e Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 18 Jun 2025 10:38:50 +0200 Subject: [PATCH 3/4] feat(formatter): add GitLab CI formatter Signed-off-by: Emilien Escalle --- src/CssLint/Formatter/FormatterFactory.php | 7 +- src/CssLint/Formatter/GitlabCiFormatter.php | 143 ++++++++++++++++++ .../Formatter/GithubActionsFormatterTest.php | 2 +- .../Formatter/GitlabCiFormatterTest.php | 133 ++++++++++++++++ 4 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/CssLint/Formatter/GitlabCiFormatter.php create mode 100644 tests/TestSuite/Formatter/GitlabCiFormatterTest.php diff --git a/src/CssLint/Formatter/FormatterFactory.php b/src/CssLint/Formatter/FormatterFactory.php index 979c8ec..cfc1eb0 100644 --- a/src/CssLint/Formatter/FormatterFactory.php +++ b/src/CssLint/Formatter/FormatterFactory.php @@ -6,6 +6,7 @@ use RuntimeException; use CssLint\Formatter\GithubActionsFormatter; +use CssLint\Formatter\GitlabCiFormatter; /** * Factory to create FormatterManager based on requested names. @@ -17,7 +18,11 @@ class FormatterFactory public function __construct() { - $availableFormatters = [new PlainFormatter(), new GithubActionsFormatter()]; + $availableFormatters = [ + new PlainFormatter(), + new GithubActionsFormatter(), + new GitlabCiFormatter(), + ]; foreach ($availableFormatters as $formatter) { $this->available[$formatter->getName()] = $formatter; } diff --git a/src/CssLint/Formatter/GitlabCiFormatter.php b/src/CssLint/Formatter/GitlabCiFormatter.php new file mode 100644 index 0000000..99d2703 --- /dev/null +++ b/src/CssLint/Formatter/GitlabCiFormatter.php @@ -0,0 +1,143 @@ + Used to track fingerprints to avoid duplicates. + * This is not strictly necessary for GitLab CI, but helps ensure unique issues. + */ + private $fingerprints = []; + + public function getName(): string + { + return 'gitlab-ci'; + } + + public function startLinting(string $source): void + { + // Initialize fingerprints to track issues + $this->fingerprints = []; + echo "["; + } + + public function printFatalError(?string $source, mixed $error): void + { + $checkName = $error instanceof Throwable ? $error::class : 'CssLint'; + $message = $error instanceof Throwable ? $error->getMessage() : (string) $error; + + $this->printIssue( + $source ?? '', + IssueSeverity::CRITICAL, + $checkName, + $message, + new Position() + ); + } + + public function printLintError(string $source, LintError $lintError): void + { + $this->printIssue( + $source, + IssueSeverity::MAJOR, + $lintError->getKey()->value, + $lintError->getMessage(), + $lintError->getStart(), + $lintError->getEnd() + ); + } + + public function endLinting(string $source, bool $isValid): void + { + echo ']'; + } + + private function printIssue(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): void + { + $this->printCommaIfNeeded(); + + $fingerprint = $this->generateFingerprint( + $path, + $severity, + $checkName, + $message, + $begin, + $end + ); + + $issue = [ + 'description' => $message, + 'check_name' => $checkName, + 'fingerprint' => $fingerprint, + 'severity' => $severity->value, + 'location' => [ + 'path' => $path, + 'positions' => [ + 'begin' => ['line' => $begin->getLine(), 'column' => $begin->getColumn()], + ], + ], + ]; + + if ($end) { + $issue['location']['positions']['end'] = [ + 'line' => $end->getLine(), + 'column' => $end->getColumn(), + ]; + } + + echo json_encode($issue); + } + + private function printCommaIfNeeded(): void + { + if ($this->fingerprints) { + echo ','; + } + } + + private function generateFingerprint(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): string + { + $attempts = 0; + while ($attempts < 10) { + + $payload = "{$path}:{$severity->value}:{$checkName}:{$message}:{$begin->getLine()}:{$begin->getColumn()}"; + if ($end) { + $payload .= ":{$end->getLine()}:{$end->getColumn()}"; + } + + if ($attempts > 0) { + $uniquid = uniqid('', true); + $payload .= ":{$uniquid}"; + } + + $fingerprint = md5($payload); + if (!in_array($fingerprint, $this->fingerprints, true)) { + $this->fingerprints[] = $fingerprint; + return $fingerprint; + } + + $attempts++; + } + + throw new RuntimeException('Failed to generate unique fingerprint after 10 attempts'); + } +} diff --git a/tests/TestSuite/Formatter/GithubActionsFormatterTest.php b/tests/TestSuite/Formatter/GithubActionsFormatterTest.php index 3330b62..aaf3799 100644 --- a/tests/TestSuite/Formatter/GithubActionsFormatterTest.php +++ b/tests/TestSuite/Formatter/GithubActionsFormatterTest.php @@ -54,7 +54,7 @@ public function testPrintLintError(): void ); $formatter = new GithubActionsFormatter(); - $this->expectOutputString("::error file=file.css,line=10,col=5::issue found" . PHP_EOL); + $this->expectOutputString("::error file=file.css,line=10,col=5::invalid_at_rule_declaration - issue found" . PHP_EOL); $formatter->printLintError('file.css', $lintError); } diff --git a/tests/TestSuite/Formatter/GitlabCiFormatterTest.php b/tests/TestSuite/Formatter/GitlabCiFormatterTest.php new file mode 100644 index 0000000..a1ebb7a --- /dev/null +++ b/tests/TestSuite/Formatter/GitlabCiFormatterTest.php @@ -0,0 +1,133 @@ +assertSame('gitlab-ci', $formatter->getName()); + } + + public function testStartAndEndLintingOutputsEmptyArray(): void + { + $formatter = new GitlabCiFormatter(); + + $this->expectOutputString('[]'); + $formatter->startLinting('file.css'); + $formatter->endLinting('file.css', false); + } + + public function testPrintFatalErrorFormatsIssueCorrectly(): void + { + $formatter = new GitlabCiFormatter(); + $error = new Exception('fatal error'); + + // Prepare expected issue + $path = 'file.css'; + $severity = 'critical'; + $checkName = get_class($error); + $message = $error->getMessage(); + $line = 1; + $column = 1; + $payload = sprintf("%s:%s:%s:%s:%d:%d", $path, $severity, $checkName, $message, $line, $column); + $fingerprint = md5($payload); + $issue = [ + 'description' => $message, + 'check_name' => $checkName, + 'fingerprint' => $fingerprint, + 'severity' => $severity, + 'location' => [ + 'path' => $path, + 'positions' => [ + 'begin' => ['line' => $line, 'column' => $column], + ], + ], + ]; + + $expected = '[' . json_encode($issue) . ']'; + + $this->expectOutputString($expected); + + $formatter->startLinting($path); + $formatter->printFatalError($path, $error); + $formatter->endLinting($path, false); + } + + public function testPrintLintErrorFormatsIssueCorrectly(): void + { + $formatter = new GitlabCiFormatter(); + $path = 'file.css'; + $line = 10; + $col = 5; + $key = LintErrorKey::INVALID_AT_RULE_DECLARATION; + $message = 'issue found'; + $lintError = new LintError( + key: $key, + message: $message, + start: new Position($line, $col), + end: new Position($line, $col) + ); + + // Compute payload and fingerprint + $severity = 'major'; + $payload = sprintf("%s:%s:%s:%s:%d:%d:%d:%d", $path, $severity, $key->value, $message, $line, $col, $line, $col); + $fingerprint = md5($payload); + + $issue = [ + 'description' => $message, + 'check_name' => $key->value, + 'fingerprint' => $fingerprint, + 'severity' => $severity, + 'location' => [ + 'path' => $path, + 'positions' => [ + 'begin' => ['line' => $line, 'column' => $col], + 'end' => ['line' => $line, 'column' => $col], + ], + ], + ]; + + $expected = '[' . json_encode($issue) . ']'; + + $this->expectOutputString($expected); + + $formatter->startLinting($path); + $formatter->printLintError($path, $lintError); + $formatter->endLinting($path, false); + } + + public function testDuplicateIssues(): void + { + $formatter = new GitlabCiFormatter(); + $path = 'file.css'; + $error = new Exception('dup'); + + + $formatter->startLinting($path); + // Print the same fatal error twice + $formatter->printFatalError($path, $error); + $formatter->printFatalError($path, $error); + $formatter->endLinting($path, false); + + $output = $this->getActualOutputForAssertion(); + $this->assertJson($output, 'Output is not valid JSON'); + $issues = json_decode($output, true); + $this->assertCount(2, $issues); + + // Ensure fingerprints are different + $fingerprints = array_map(fn($issue) => $issue['fingerprint'], $issues); + $this->assertCount(count(array_unique($fingerprints)), $fingerprints, 'Duplicate fingerprints found in output'); + } +} From 93f1950949f7d8877361baa4617c6407e1d46a4d Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 18 Jun 2025 18:13:18 +0200 Subject: [PATCH 4/4] feat(output): allows outputs in stdout or file Signed-off-by: Emilien Escalle --- docs/usage.md | 24 ++- src/CssLint/Cli.php | 108 +++++----- src/CssLint/CliArgs.php | 47 ++-- src/CssLint/Formatter/FormatterFactory.php | 78 ------- src/CssLint/Formatter/FormatterManager.php | 52 ----- .../Formatter/GithubActionsFormatter.php | 54 ----- src/CssLint/Output/FileOutput.php | 50 +++++ .../Output/Formatter/FormatterFactory.php | 79 +++++++ .../Formatter/FormatterInterface.php | 10 +- .../Output/Formatter/FormatterManager.php | 57 +++++ .../Formatter/GithubActionsFormatter.php | 117 ++++++++++ .../Formatter/GitlabCiFormatter.php | 30 +-- .../{ => Output}/Formatter/PlainFormatter.php | 21 +- src/CssLint/Output/OutputInterface.php | 21 ++ src/CssLint/Output/StdoutOutput.php | 21 ++ tests/TestSuite/CliTest.php | 28 +++ .../Formatter/GithubActionsFormatterTest.php | 80 ------- .../Formatter/PlainFormatterTest.php | 77 ------- tests/TestSuite/Output/FileOutputTest.php | 201 ++++++++++++++++++ .../Formatter/FormatterFactoryTest.php | 10 +- .../Formatter/FormatterManagerTest.php | 53 +++-- .../Formatter/GithubActionsFormatterTest.php | 106 +++++++++ .../Formatter/GitlabCiFormatterTest.php | 76 ++++--- .../Output/Formatter/PlainFormatterTest.php | 103 +++++++++ tests/TestSuite/Output/StdoutOutputTest.php | 122 +++++++++++ 25 files changed, 1130 insertions(+), 495 deletions(-) delete mode 100644 src/CssLint/Formatter/FormatterFactory.php delete mode 100644 src/CssLint/Formatter/FormatterManager.php delete mode 100644 src/CssLint/Formatter/GithubActionsFormatter.php create mode 100644 src/CssLint/Output/FileOutput.php create mode 100644 src/CssLint/Output/Formatter/FormatterFactory.php rename src/CssLint/{ => Output}/Formatter/FormatterInterface.php (87%) create mode 100644 src/CssLint/Output/Formatter/FormatterManager.php create mode 100644 src/CssLint/Output/Formatter/GithubActionsFormatter.php rename src/CssLint/{ => Output}/Formatter/GitlabCiFormatter.php (86%) rename src/CssLint/{ => Output}/Formatter/PlainFormatter.php (54%) create mode 100644 src/CssLint/Output/OutputInterface.php create mode 100644 src/CssLint/Output/StdoutOutput.php delete mode 100644 tests/TestSuite/Formatter/GithubActionsFormatterTest.php delete mode 100644 tests/TestSuite/Formatter/PlainFormatterTest.php create mode 100644 tests/TestSuite/Output/FileOutputTest.php rename tests/TestSuite/{ => Output}/Formatter/FormatterFactoryTest.php (68%) rename tests/TestSuite/{ => Output}/Formatter/FormatterManagerTest.php (69%) create mode 100644 tests/TestSuite/Output/Formatter/GithubActionsFormatterTest.php rename tests/TestSuite/{ => Output}/Formatter/GitlabCiFormatterTest.php (63%) create mode 100644 tests/TestSuite/Output/Formatter/PlainFormatterTest.php create mode 100644 tests/TestSuite/Output/StdoutOutputTest.php diff --git a/docs/usage.md b/docs/usage.md index 624ff62..4f6d57b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,7 +27,7 @@ Result: Usage: ------ - php-css-lint [--options='{ }'] [--formatter=plain|json] input_to_lint + php-css-lint [--options='{ }'] [--formatter=name] [--formatter=name:path] input_to_lint Arguments: ---------- @@ -41,11 +41,14 @@ Arguments: Example: --options='{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }' --formatter - The formatter(s) to be used - If not specified, the first available formatter will be used. - Multiple formatters can be specified as a comma-separated list. - Available formatters: plain, json - Example: --formatter=plain + 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) @@ -67,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 diff --git a/src/CssLint/Cli.php b/src/CssLint/Cli.php index 7d52318..e20b312 100644 --- a/src/CssLint/Cli.php +++ b/src/CssLint/Cli.php @@ -6,8 +6,8 @@ use RuntimeException; use Throwable; -use CssLint\Formatter\FormatterInterface; -use CssLint\Formatter\FormatterFactory; +use CssLint\Output\Formatter\FormatterFactory; +use CssLint\Output\Formatter\FormatterManager; use Generator; /** @@ -25,7 +25,7 @@ class Cli private ?FormatterFactory $formatterFactory = null; - private FormatterInterface $formatterManager; + private FormatterManager $formatterManager; /** * Entrypoint of the cli, will execute the linter according to the given arguments @@ -37,10 +37,10 @@ public function run(array $arguments): int $cliArgs = $this->parseArguments($arguments); try { - $this->formatterManager = $this->getFormatterFactory()->create($cliArgs->formatter); + $this->formatterManager = $this->getFormatterFactory()->create($cliArgs->formatters); } catch (RuntimeException $error) { // report invalid formatter names via default (plain) formatter - $this->getFormatterFactory()->create(null)->printFatalError(null, $error); + $this->getFormatterFactory()->create()->printFatalError(null, $error); return self::RETURN_CODE_ERROR; } @@ -69,50 +69,60 @@ private function printUsage(): void $availableFormatters = $this->getFormatterFactory()->getAvailableFormatters(); $defaultFormatter = $availableFormatters[0]; - $this->printLine('Usage:' . PHP_EOL . - '------' . PHP_EOL . - PHP_EOL . - ' ' . self::SCRIPT_NAME . " [--options='{ }'] [--formatter=plain|json] 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' . PHP_EOL . - ' If not specified, the first available formatter will be used.' . PHP_EOL . - ' Multiple formatters can be specified as a comma-separated list.' . PHP_EOL . - ' Available formatters: ' . implode(', ', $availableFormatters) . PHP_EOL . - ' Example: --formatter=' . $defaultFormatter . 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); + $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 + ); } /** diff --git a/src/CssLint/CliArgs.php b/src/CssLint/CliArgs.php index 38dc7c8..5b3c558 100644 --- a/src/CssLint/CliArgs.php +++ b/src/CssLint/CliArgs.php @@ -7,7 +7,6 @@ /** * @package CssLint * @phpstan-type Arguments string[] - * @phpstan-type ParsedArguments array */ class CliArgs { @@ -16,9 +15,11 @@ class CliArgs public ?string $options = null; /** - * Output formatter type + * Array of formatter specifications with their output destinations + * Format: ['plain' => null, 'gitlab-ci' => '/path/to/report.json'] + * @var array */ - public ?string $formatter = null; + public array $formatters = []; /** * Constructor @@ -37,39 +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']; - } - if (!empty($parsedArguments['formatter'])) { - $this->formatter = $parsedArguments['formatter']; - } + $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); + } } } } + } - return $aParsedArguments; + /** + * Parse a formatter specification like "plain" or "gitlab-ci:/path/to/file.json" + */ + private function parseFormatterSpec(string $formatterSpec): void + { + $colonPosition = strpos($formatterSpec, ':'); + + 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; + } } } diff --git a/src/CssLint/Formatter/FormatterFactory.php b/src/CssLint/Formatter/FormatterFactory.php deleted file mode 100644 index cfc1eb0..0000000 --- a/src/CssLint/Formatter/FormatterFactory.php +++ /dev/null @@ -1,78 +0,0 @@ - */ - private array $available; - - public function __construct() - { - $availableFormatters = [ - new PlainFormatter(), - new GithubActionsFormatter(), - new GitlabCiFormatter(), - ]; - foreach ($availableFormatters as $formatter) { - $this->available[$formatter->getName()] = $formatter; - } - } - - /** - * Create a FormatterManager based on a comma-separated list of formatter names. - * @param string|null $formatterArg e.g. 'plain,json' - * @return FormatterManager - * @throws RuntimeException on invalid formatter names - */ - public function create(?string $formatterArg): FormatterManager - { - $names = array_filter(array_map('trim', explode(',', (string) $formatterArg))); - $instances = []; - $invalid = []; - - $available = $this->getAvailableFormatters(); - - foreach ($names as $name) { - if (in_array($name, $available, true)) { - $instances[] = $this->available[$name]; - } else { - $invalid[] = $name; - } - } - - if (!empty($invalid)) { - throw new RuntimeException('Invalid formatter(s): ' . implode(', ', $invalid)); - } - - if (empty($instances)) { - // Return the first available formatter if none specified - // If no formatters are available, throw an exception - if (empty($this->available)) { - throw new RuntimeException('No formatters available'); - } - - $instances[] = $this->available[array_key_first($this->available)]; - } - - return new FormatterManager($instances); - } - - /** - * Get the names of all available formatters. - * @return non-empty-string[] List of formatter names - */ - public function getAvailableFormatters(): array - { - return array_keys($this->available); - } -} diff --git a/src/CssLint/Formatter/FormatterManager.php b/src/CssLint/Formatter/FormatterManager.php deleted file mode 100644 index d89efc0..0000000 --- a/src/CssLint/Formatter/FormatterManager.php +++ /dev/null @@ -1,52 +0,0 @@ -formatters as $formatter) { - $formatter->startLinting($source); - } - } - - public function printFatalError(?string $source, mixed $error): void - { - foreach ($this->formatters as $formatter) { - $formatter->printFatalError($source, $error); - } - } - - public function printLintError(string $source, mixed $error): void - { - foreach ($this->formatters as $formatter) { - $formatter->printLintError($source, $error); - } - } - - public function endLinting(string $source, bool $isValid): void - { - foreach ($this->formatters as $formatter) { - $formatter->endLinting($source, $isValid); - } - } - - public function getName(): string - { - throw new RuntimeException('FormatterManager does not have a single name. Use the names of individual formatters instead.'); - } -} diff --git a/src/CssLint/Formatter/GithubActionsFormatter.php b/src/CssLint/Formatter/GithubActionsFormatter.php deleted file mode 100644 index 6904405..0000000 --- a/src/CssLint/Formatter/GithubActionsFormatter.php +++ /dev/null @@ -1,54 +0,0 @@ -getMessage() : (string) $error; - $location = ''; - if ($source) { - $location = "file={$source}"; - } - echo "::error {$location}::{$message}" . PHP_EOL; - } - - public function printLintError(string $source, LintError $lintError): void - { - $key = $lintError->getKey(); - $message = $lintError->getMessage(); - $startPosition = $lintError->getStart(); - $line = $startPosition->getLine(); - $col = $startPosition->getColumn(); - echo "::error file={$source},line={$line},col={$col}::{$key->value} - {$message}" . PHP_EOL; - } - - public function endLinting(string $source, bool $isValid): void - { - if ($isValid) { - echo "::notice ::Success: {$source} is valid." . PHP_EOL; - } else { - echo "::error file={$source}::{$source} is invalid CSS." . PHP_EOL; - } - echo "::endgroup::" . PHP_EOL; - } -} diff --git a/src/CssLint/Output/FileOutput.php b/src/CssLint/Output/FileOutput.php new file mode 100644 index 0000000..4ffcb48 --- /dev/null +++ b/src/CssLint/Output/FileOutput.php @@ -0,0 +1,50 @@ +fileHandle = $fileHandle; + } + + public function write(string $content): void + { + if (fwrite($this->fileHandle, $content) === false) { + throw new RuntimeException('Failed to write to file'); + } + } + + public function writeLine(string $content): void + { + $this->write($content . PHP_EOL); + } + + public function __destruct() + { + if (is_resource($this->fileHandle)) { + fclose($this->fileHandle); + } + } +} diff --git a/src/CssLint/Output/Formatter/FormatterFactory.php b/src/CssLint/Output/Formatter/FormatterFactory.php new file mode 100644 index 0000000..86b8215 --- /dev/null +++ b/src/CssLint/Output/Formatter/FormatterFactory.php @@ -0,0 +1,79 @@ + */ + private array $formaters; + + public function __construct() + { + $availableFormatters = [ + new PlainFormatter(), + new GitlabCiFormatter(), + new GithubActionsFormatter(), + ]; + foreach ($availableFormatters as $formatter) { + $this->formaters[$formatter->getName()] = $formatter; + } + } + + /** + * Create a FormatterManager based on formatter specifications with output destinations. + * @param array $formatterSpecs Array of formatter name => output path + * @return FormatterManager + * @throws RuntimeException on invalid formatter names or file creation errors + */ + public function create(?array $formatterSpecs = null): FormatterManager + { + $availableNames = $this->getAvailableFormatters(); + if (empty($formatterSpecs)) { + // Use default formatter (to stdout) + $defaultFormatter = $availableNames[0]; + $formatterSpecs = [$defaultFormatter => null]; + } + + /** @var OutputFormatters $outputFormatters */ + $outputFormatters = []; + + foreach ($formatterSpecs as $formatterName => $outputPath) { + if (!in_array($formatterName, $availableNames, true)) { + throw new RuntimeException("Invalid formatter: {$formatterName}"); + } + + $formatter = $this->formaters[$formatterName]; + + if ($outputPath === null) { + // Output to stdout + $outputFormatters[] = [new StdoutOutput(), $formatter]; + } else { + // Output to file + $outputFormatters[] = [new FileOutput($outputPath), $formatter]; + } + } + + return new FormatterManager($outputFormatters); + } + + /** + * Get the names of all available formatters. + * @return non-empty-array List of formatter names + */ + public function getAvailableFormatters(): array + { + return array_keys($this->formaters); + } +} diff --git a/src/CssLint/Formatter/FormatterInterface.php b/src/CssLint/Output/Formatter/FormatterInterface.php similarity index 87% rename from src/CssLint/Formatter/FormatterInterface.php rename to src/CssLint/Output/Formatter/FormatterInterface.php index cb5a091..900482f 100644 --- a/src/CssLint/Formatter/FormatterInterface.php +++ b/src/CssLint/Output/Formatter/FormatterInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CssLint\Formatter; +namespace CssLint\Output\Formatter; use CssLint\LintError; use Throwable; @@ -21,7 +21,7 @@ public function getName(): string; * * @param string $source The source being linted (e.g., "CSS file \"...\""). */ - public function startLinting(string $source): void; + public function startLinting(string $source): string; /** * Output a fatal error message. @@ -29,7 +29,7 @@ public function startLinting(string $source): void; * @param string|null $source The source being linted (e.g., "CSS file \"...\""). * @param Throwable|string $error The exception or error that occurred, which may include a message and stack trace. */ - public function printFatalError(?string $source, mixed $error): void; + public function printFatalError(?string $source, mixed $error): string; /** * Output a parsing or runtime error message. @@ -37,7 +37,7 @@ public function printFatalError(?string $source, mixed $error): void; * @param string $source The source being linted (e.g., "CSS file \"...\""). * @param LintError $error The error to be printed, which may include details like line number, column, and message. */ - public function printLintError(string $source, LintError $error): void; + public function printLintError(string $source, LintError $error): string; /** @@ -46,5 +46,5 @@ public function printLintError(string $source, LintError $error): void; * @param string $source The source being linted (e.g., "CSS file \"...\""). * @param bool $isValid Whether the source is valid CSS. */ - public function endLinting(string $source, bool $isValid): void; + public function endLinting(string $source, bool $isValid): string; } diff --git a/src/CssLint/Output/Formatter/FormatterManager.php b/src/CssLint/Output/Formatter/FormatterManager.php new file mode 100644 index 0000000..959ae00 --- /dev/null +++ b/src/CssLint/Output/Formatter/FormatterManager.php @@ -0,0 +1,57 @@ + + */ +class FormatterManager +{ + /** + * Constructor for FormatterManager. + * @param OutputFormatters $outputFormatters List of output formatters tuples to manage. + */ + public function __construct(private readonly array $outputFormatters) {} + + public function startLinting(string $source): void + { + foreach ($this->outputFormatters as [$output, $formatter]) { + $output->write($formatter->startLinting($source)); + } + } + + /** + * Prints a fatal error message for the given source. + * @param string|null $source The source being linted (e.g., "CSS file \"...\""). + * @param Throwable|string $error The exception or error that occurred, which may include a message and stack trace. + * @return void + */ + public function printFatalError(?string $source, mixed $error): void + { + foreach ($this->outputFormatters as [$output, $formatter]) { + $output->write($formatter->printFatalError($source, $error)); + } + } + + public function printLintError(string $source, LintError $error): void + { + foreach ($this->outputFormatters as [$output, $formatter]) { + $output->write($formatter->printLintError($source, $error)); + } + } + + public function endLinting(string $source, bool $isValid): void + { + foreach ($this->outputFormatters as [$output, $formatter]) { + $output->write($formatter->endLinting($source, $isValid)); + } + } +} diff --git a/src/CssLint/Output/Formatter/GithubActionsFormatter.php b/src/CssLint/Output/Formatter/GithubActionsFormatter.php new file mode 100644 index 0000000..e5d176c --- /dev/null +++ b/src/CssLint/Output/Formatter/GithubActionsFormatter.php @@ -0,0 +1,117 @@ +getMessage() : (string) $error; + + $annotationProperties = []; + if ($source) { + $annotationProperties['file'] = $source; + } + + return $this->printAnnotation(AnnotationType::ERROR, $message, $annotationProperties); + } + + public function printLintError(string $source, LintError $lintError): string + { + $key = $lintError->getKey(); + $message = $lintError->getMessage(); + $startPosition = $lintError->getStart(); + $endPosition = $lintError->getEnd(); + return $this->printAnnotation( + AnnotationType::ERROR, + sprintf('%s - %s', $key->value, $message), + [ + 'file' => $source, + 'line' => $startPosition->getLine(), + 'col' => $startPosition->getColumn(), + 'endLine' => $endPosition->getLine(), + 'endColumn' => $endPosition->getColumn(), + ] + ); + } + + public function endLinting(string $source, bool $isValid): string + { + $content = ''; + if ($isValid) { + $content .= $this->printAnnotation(AnnotationType::NOTICE, "Success: {$source} is valid."); + } else { + $content .= $this->printAnnotation(AnnotationType::ERROR, "{$source} is invalid CSS.", ['file' => $source]); + } + $content .= "::endgroup::" . PHP_EOL; + return $content; + } + + /** + * @param AnnotationProperties $annotationProperties + */ + private function printAnnotation(AnnotationType $type, string $message, array $annotationProperties = []): string + { + $properties = $this->sanitizeAnnotationProperties($annotationProperties); + $command = sprintf('::%s %s::%s', $type->value, $properties, $message); + // Sanitize command + $command = str_replace(['%', "\r", "\n"], ['%25', '%0D', '%0A'], $command); + return $command . PHP_EOL; + } + + /** + * @param AnnotationProperties $annotationProperties + */ + private function sanitizeAnnotationProperties(array $annotationProperties): string + { + $nonNullProperties = array_filter( + $annotationProperties, + static fn($value): bool => $value !== null + ); + $sanitizedProperties = array_map( + fn($key, $value): string => sprintf('%s=%s', $key, $this->sanitizeAnnotationProperty($value)), + array_keys($nonNullProperties), + $nonNullProperties + ); + return implode(',', $sanitizedProperties); + } + + /** + * @param string|int|null $value + */ + private function sanitizeAnnotationProperty($value): string + { + if ($value === null || $value === '') { + return ''; + } + $value = (string) $value; + return str_replace(['%', "\r", "\n", ':', ','], ['%25', '%0D', '%0A', '%3A', '%2C'], $value); + } +} diff --git a/src/CssLint/Formatter/GitlabCiFormatter.php b/src/CssLint/Output/Formatter/GitlabCiFormatter.php similarity index 86% rename from src/CssLint/Formatter/GitlabCiFormatter.php rename to src/CssLint/Output/Formatter/GitlabCiFormatter.php index 99d2703..cbe1eda 100644 --- a/src/CssLint/Formatter/GitlabCiFormatter.php +++ b/src/CssLint/Output/Formatter/GitlabCiFormatter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CssLint\Formatter; +namespace CssLint\Output\Formatter; use CssLint\LintError; use CssLint\Position; @@ -33,19 +33,19 @@ public function getName(): string return 'gitlab-ci'; } - public function startLinting(string $source): void + public function startLinting(string $source): string { // Initialize fingerprints to track issues $this->fingerprints = []; - echo "["; + return "["; } - public function printFatalError(?string $source, mixed $error): void + public function printFatalError(?string $source, mixed $error): string { $checkName = $error instanceof Throwable ? $error::class : 'CssLint'; $message = $error instanceof Throwable ? $error->getMessage() : (string) $error; - $this->printIssue( + return $this->printIssue( $source ?? '', IssueSeverity::CRITICAL, $checkName, @@ -54,9 +54,9 @@ public function printFatalError(?string $source, mixed $error): void ); } - public function printLintError(string $source, LintError $lintError): void + public function printLintError(string $source, LintError $lintError): string { - $this->printIssue( + return $this->printIssue( $source, IssueSeverity::MAJOR, $lintError->getKey()->value, @@ -66,14 +66,14 @@ public function printLintError(string $source, LintError $lintError): void ); } - public function endLinting(string $source, bool $isValid): void + public function endLinting(string $source, bool $isValid): string { - echo ']'; + return ']'; } - private function printIssue(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): void + private function printIssue(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): string { - $this->printCommaIfNeeded(); + $content = $this->printCommaIfNeeded(); $fingerprint = $this->generateFingerprint( $path, @@ -104,14 +104,16 @@ private function printIssue(string $path, IssueSeverity $severity, string $check ]; } - echo json_encode($issue); + $content .= json_encode($issue); + return $content; } - private function printCommaIfNeeded(): void + private function printCommaIfNeeded(): string { if ($this->fingerprints) { - echo ','; + return ','; } + return ''; } private function generateFingerprint(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): string diff --git a/src/CssLint/Formatter/PlainFormatter.php b/src/CssLint/Output/Formatter/PlainFormatter.php similarity index 54% rename from src/CssLint/Formatter/PlainFormatter.php rename to src/CssLint/Output/Formatter/PlainFormatter.php index 0ae4496..1e3b7ce 100644 --- a/src/CssLint/Formatter/PlainFormatter.php +++ b/src/CssLint/Output/Formatter/PlainFormatter.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace CssLint\Formatter; +namespace CssLint\Output\Formatter; use CssLint\LintError; -use Generator; use Throwable; /** @@ -13,12 +12,12 @@ */ class PlainFormatter implements FormatterInterface { - public function startLinting(string $source): void + public function startLinting(string $source): string { - echo "# Lint {$source}..." . PHP_EOL; + return "# Lint {$source}..." . PHP_EOL; } - public function printFatalError(?string $source, mixed $error): void + public function printFatalError(?string $source, mixed $error): string { if ($error instanceof Throwable) { $error = $error->getMessage(); @@ -28,20 +27,20 @@ public function printFatalError(?string $source, mixed $error): void $error = "$source - " . $error; } - echo "\033[31m/!\ Error: " . $error . "\033[0m" . PHP_EOL; + return "\033[31m/!\ Error: " . $error . "\033[0m" . PHP_EOL; } - public function printLintError(string $source, LintError $lintError): void + public function printLintError(string $source, LintError $lintError): string { - echo "\033[31m - " . $lintError . "\033[0m" . PHP_EOL; + return "\033[31m - " . $lintError . "\033[0m" . PHP_EOL; } - public function endLinting(string $source, bool $isValid): void + public function endLinting(string $source, bool $isValid): string { if ($isValid) { - echo "\033[32m => Success: {$source} is valid.\033[0m" . PHP_EOL . PHP_EOL; + return "\033[32m => Success: {$source} is valid.\033[0m" . PHP_EOL . PHP_EOL; } else { - echo "\033[31m => Failure: {$source} is invalid CSS.\033[0m" . PHP_EOL; + return "\033[31m => Failure: {$source} is invalid CSS.\033[0m" . PHP_EOL; } } diff --git a/src/CssLint/Output/OutputInterface.php b/src/CssLint/Output/OutputInterface.php new file mode 100644 index 0000000..8d3f998 --- /dev/null +++ b/src/CssLint/Output/OutputInterface.php @@ -0,0 +1,21 @@ +testFixturesDir = realpath(__DIR__ . '/../fixtures'); + $this->tempDir = sys_get_temp_dir(); $this->cli = new Cli(); } + protected function tearDown(): void + { + // Clean up any test files + $pattern = $this->tempDir . '/test_file_output_*.txt'; + foreach (glob($pattern) as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + public function testRunWithoutArgumentMustReturnsErrorCode() { $this->expectOutputRegex( @@ -159,6 +175,18 @@ public function testRunWithFormatterArgumentShouldReturnSuccessCode() $this->assertEquals(0, $this->cli->run(['php-css-lint', '--formatter=github-actions', $fileToLint]), $this->getActualOutput()); } + public function testRunWithFormatterAndPathArgumentShouldReturnSuccessCode() + { + $fileToLint = $this->testFixturesDir . '/valid.css'; + $outputFile = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + + $this->expectOutputString(""); + $this->assertEquals(0, $this->cli->run(['php-css-lint', '--formatter=gitlab-ci:' . $outputFile, $fileToLint])); + + $this->assertFileExists($outputFile); + $this->assertStringContainsString("[]", file_get_contents($outputFile)); + } + public function validCssFilesProvider(): array { return [ diff --git a/tests/TestSuite/Formatter/GithubActionsFormatterTest.php b/tests/TestSuite/Formatter/GithubActionsFormatterTest.php deleted file mode 100644 index aaf3799..0000000 --- a/tests/TestSuite/Formatter/GithubActionsFormatterTest.php +++ /dev/null @@ -1,80 +0,0 @@ -assertSame('github-actions', $formatter->getName()); - } - - public function testStartLintingOutputsGroup(): void - { - $formatter = new GithubActionsFormatter(); - $this->expectOutputString("::group::Lint file.css" . PHP_EOL); - $formatter->startLinting('file.css'); - } - - public function testPrintFatalErrorWithThrowable(): void - { - $formatter = new GithubActionsFormatter(); - $error = new Exception('fatal error'); - $this->expectOutputString("::error file=file.css::fatal error" . PHP_EOL); - $formatter->printFatalError('file.css', $error); - } - - public function testPrintFatalErrorWithoutSource(): void - { - $formatter = new GithubActionsFormatter(); - $this->expectOutputString("::error ::some error" . PHP_EOL); - $formatter->printFatalError(null, 'some error'); - } - - public function testPrintLintError(): void - { - $positionArr = ['line' => 10, 'column' => 5]; - $lintError = new LintError( - key: LintErrorKey::INVALID_AT_RULE_DECLARATION, - message: 'issue found', - start: new Position($positionArr['line'], $positionArr['column']), - end: new Position($positionArr['line'], $positionArr['column']) - ); - - $formatter = new GithubActionsFormatter(); - $this->expectOutputString("::error file=file.css,line=10,col=5::invalid_at_rule_declaration - issue found" . PHP_EOL); - $formatter->printLintError('file.css', $lintError); - } - - public function testEndLintingOutputsEndGroup(): void - { - $formatter = new GithubActionsFormatter(); - $this->expectOutputString( - "::notice ::Success: file.css is valid." . PHP_EOL . - "::endgroup::" . PHP_EOL - ); - $formatter->endLinting('file.css', true); - } - - public function testFactoryIntegration(): void - { - $factory = new FormatterFactory(); - $available = $factory->getAvailableFormatters(); - $this->assertContains('github-actions', $available); - - $manager = $factory->create('github-actions'); - $this->assertInstanceOf(FormatterManager::class, $manager); - } -} diff --git a/tests/TestSuite/Formatter/PlainFormatterTest.php b/tests/TestSuite/Formatter/PlainFormatterTest.php deleted file mode 100644 index 2aa7586..0000000 --- a/tests/TestSuite/Formatter/PlainFormatterTest.php +++ /dev/null @@ -1,77 +0,0 @@ -assertSame('plain', $formatter->getName()); - } - - public function testStartLintingOutputsCorrectMessage(): void - { - $formatter = new PlainFormatter(); - $this->expectOutputString("# Lint file.css..." . PHP_EOL); - $formatter->startLinting('file.css'); - } - - public function testPrintFatalErrorWithThrowableOutputsColoredMessage(): void - { - $formatter = new PlainFormatter(); - $error = new Exception('fatal error'); - $this->expectOutputString( - "\033[31m/!\ Error: file.css - fatal error\033[0m" . PHP_EOL - ); - $formatter->printFatalError('file.css', $error); - } - - public function testPrintFatalErrorWithStringOutputsColoredMessage(): void - { - $formatter = new PlainFormatter(); - $message = 'some error'; - $this->expectOutputString( - "\033[31m/!\ Error: file.css - some error\033[0m" . PHP_EOL - ); - $formatter->printFatalError('file.css', $message); - } - - public function testPrintLintErrorOutputsColoredMessage(): void - { - $formatter = new PlainFormatter(); - // Using a LintError stub to provide a string representation - $error = $this->createStub(LintError::class); - $error->method('__toString')->willReturn('lint issue'); - - $this->expectOutputString( - "\033[31m - lint issue\033[0m" . PHP_EOL - ); - $formatter->printLintError('file.css', $error); - } - - public function testEndLintingOutputsSuccessWhenValid(): void - { - $formatter = new PlainFormatter(); - $this->expectOutputString( - "\033[32m => Success: file.css is valid.\033[0m" . PHP_EOL . PHP_EOL - ); - $formatter->endLinting('file.css', true); - } - - public function testEndLintingOutputsFailureWhenInvalid(): void - { - $formatter = new PlainFormatter(); - $this->expectOutputString( - "\033[31m => Failure: file.css is invalid CSS.\033[0m" . PHP_EOL - ); - $formatter->endLinting('file.css', false); - } -} diff --git a/tests/TestSuite/Output/FileOutputTest.php b/tests/TestSuite/Output/FileOutputTest.php new file mode 100644 index 0000000..9f31e08 --- /dev/null +++ b/tests/TestSuite/Output/FileOutputTest.php @@ -0,0 +1,201 @@ +tempDir = sys_get_temp_dir(); + } + + protected function tearDown(): void + { + // Clean up any test files + $pattern = $this->tempDir . '/test_file_output_*.txt'; + foreach (glob($pattern) as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + public function testConstructorCreatesFile(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + + $output = new FileOutput($filePath); + + $this->assertFileExists($filePath); + + // Clean up + unset($output); + if (file_exists($filePath)) { + unlink($filePath); + } + } + + public function testConstructorThrowsExceptionForInvalidPath(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Directory is not writable: /invalid/path'); + + new FileOutput('/invalid/path/file.txt'); + } + + public function testWrite(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $output->write('Hello, World!'); + + $content = file_get_contents($filePath); + $this->assertEquals('Hello, World!', $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testWriteMultipleContents(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $output->write('First '); + $output->write('Second '); + $output->write('Third'); + + $content = file_get_contents($filePath); + $this->assertEquals('First Second Third', $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testWriteLine(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $output->writeLine('Line 1'); + $output->writeLine('Line 2'); + + $content = file_get_contents($filePath); + $expected = 'Line 1' . PHP_EOL . 'Line 2' . PHP_EOL; + $this->assertEquals($expected, $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testWriteEmptyString(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $output->write(''); + + $content = file_get_contents($filePath); + $this->assertEquals('', $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testWriteLineEmptyString(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $output->writeLine(''); + + $content = file_get_contents($filePath); + $this->assertEquals(PHP_EOL, $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testFlush(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $output->write('Test content'); + + // After flush, content should definitely be written + $content = file_get_contents($filePath); + $this->assertEquals('Test content', $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testDestructorClosesFile(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + + $output = new FileOutput($filePath); + $output->write('Test content'); + + // Explicitly destroy the object + unset($output); + + // File should be closed and content written + $this->assertFileExists($filePath); + $content = file_get_contents($filePath); + $this->assertEquals('Test content', $content); + + // Clean up + unlink($filePath); + } + + public function testWriteSpecialCharacters(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $specialContent = "Special chars: \n\t\r\0\x0B àéîöü 🚀"; + $output->write($specialContent); + + $content = file_get_contents($filePath); + $this->assertEquals($specialContent, $content); + + // Clean up + unset($output); + unlink($filePath); + } + + public function testWriteUnicodeContent(): void + { + $filePath = $this->tempDir . '/test_file_output_' . uniqid() . '.txt'; + $output = new FileOutput($filePath); + + $unicodeContent = 'Unicode: 中文 العربية русский 日本語'; + $output->writeLine($unicodeContent); + + $content = file_get_contents($filePath); + $this->assertEquals($unicodeContent . PHP_EOL, $content); + + // Clean up + unset($output); + unlink($filePath); + } +} diff --git a/tests/TestSuite/Formatter/FormatterFactoryTest.php b/tests/TestSuite/Output/Formatter/FormatterFactoryTest.php similarity index 68% rename from tests/TestSuite/Formatter/FormatterFactoryTest.php rename to tests/TestSuite/Output/Formatter/FormatterFactoryTest.php index db6096a..5a2fee1 100644 --- a/tests/TestSuite/Formatter/FormatterFactoryTest.php +++ b/tests/TestSuite/Output/Formatter/FormatterFactoryTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\TestSuite\Formatter; +namespace Tests\TestSuite\Output\Formatter; -use CssLint\Formatter\FormatterFactory; -use CssLint\Formatter\FormatterManager; +use CssLint\Output\Formatter\FormatterFactory; +use CssLint\Output\Formatter\FormatterManager; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -22,7 +22,7 @@ public function testCreateWithInvalidNameThrowsException(): void { $factory = new FormatterFactory(); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Invalid formatter(s): invalid'); - $factory->create('invalid'); + $this->expectExceptionMessage('Invalid formatter: invalid'); + $factory->create(['invalid' => null]); } } diff --git a/tests/TestSuite/Formatter/FormatterManagerTest.php b/tests/TestSuite/Output/Formatter/FormatterManagerTest.php similarity index 69% rename from tests/TestSuite/Formatter/FormatterManagerTest.php rename to tests/TestSuite/Output/Formatter/FormatterManagerTest.php index 1e2e60e..e5f65ec 100644 --- a/tests/TestSuite/Formatter/FormatterManagerTest.php +++ b/tests/TestSuite/Output/Formatter/FormatterManagerTest.php @@ -2,11 +2,12 @@ declare(strict_types=1); -namespace Tests\TestSuite\Formatter; +namespace Tests\TestSuite\Output\Formatter; -use CssLint\Formatter\FormatterManager; -use CssLint\Formatter\FormatterInterface; +use CssLint\Output\Formatter\FormatterManager; +use CssLint\Output\Formatter\FormatterInterface; use CssLint\LintError; +use CssLint\Output\OutputInterface; use PHPUnit\Framework\TestCase; use RuntimeException; use Exception; @@ -15,6 +16,9 @@ class FormatterManagerTest extends TestCase { public function testStartLintingPropagatesToAllFormatters(): void { + // Aarrange + $output = $this->createMock(OutputInterface::class); + $formatter1 = $this->createMock(FormatterInterface::class); $formatter1->expects($this->once()) ->method('startLinting') @@ -25,14 +29,21 @@ public function testStartLintingPropagatesToAllFormatters(): void ->method('startLinting') ->with('source.css'); - $manager = new FormatterManager([$formatter1, $formatter2]); + // Act + $manager = new FormatterManager([ + [$output, $formatter1], + [$output, $formatter2], + ]); $manager->startLinting('source.css'); } public function testPrintFatalErrorPropagatesToAllFormatters(): void { + // Arrange $error = new Exception('fatal error'); + $output = $this->createMock(OutputInterface::class); + $formatter1 = $this->createMock(FormatterInterface::class); $formatter1->expects($this->once()) ->method('printFatalError') @@ -43,14 +54,19 @@ public function testPrintFatalErrorPropagatesToAllFormatters(): void ->method('printFatalError') ->with('file.css', $error); - $manager = new FormatterManager([$formatter1, $formatter2]); + $manager = new FormatterManager([[$output, $formatter1], [$output, $formatter2]]); + + // Act $manager->printFatalError('file.css', $error); } public function testPrintLintErrorPropagatesToAllFormatters(): void { + // Arrange $lintError = $this->createMock(LintError::class); + $output = $this->createMock(OutputInterface::class); + $formatter1 = $this->createMock(FormatterInterface::class); $formatter1->expects($this->once()) ->method('printLintError') @@ -61,12 +77,21 @@ public function testPrintLintErrorPropagatesToAllFormatters(): void ->method('printLintError') ->with('file.css', $lintError); - $manager = new FormatterManager([$formatter1, $formatter2]); + + $manager = new FormatterManager([ + [$output, $formatter1], + [$output, $formatter2], + ]); + + // Act $manager->printLintError('file.css', $lintError); } public function testEndLintingPropagatesToAllFormatters(): void { + // Arrange + $output = $this->createMock(OutputInterface::class); + $formatter1 = $this->createMock(FormatterInterface::class); $formatter1->expects($this->once()) ->method('endLinting') @@ -77,16 +102,12 @@ public function testEndLintingPropagatesToAllFormatters(): void ->method('endLinting') ->with('file.css', true); - $manager = new FormatterManager([$formatter1, $formatter2]); - $manager->endLinting('file.css', true); - } - - public function testGetNameThrowsRuntimeException(): void - { - $formatter = $this->createMock(FormatterInterface::class); + $manager = new FormatterManager([ + [$output, $formatter1], + [$output, $formatter2], + ]); - $manager = new FormatterManager([$formatter]); - $this->expectException(RuntimeException::class); - $manager->getName(); + // Act + $manager->endLinting('file.css', true); } } diff --git a/tests/TestSuite/Output/Formatter/GithubActionsFormatterTest.php b/tests/TestSuite/Output/Formatter/GithubActionsFormatterTest.php new file mode 100644 index 0000000..7f85406 --- /dev/null +++ b/tests/TestSuite/Output/Formatter/GithubActionsFormatterTest.php @@ -0,0 +1,106 @@ +formatter = new GithubActionsFormatter(); + } + + public function testGetNameReturnsGithubActions(): void + { + + $this->assertSame('github-actions', $this->formatter->getName()); + } + + public function testStartLintingOutputsGroup(): void + { + // Act + $content = $this->formatter->startLinting('file.css'); + + // Assert + $this->assertSame("::group::Lint file.css" . PHP_EOL, $content); + } + + public function testPrintFatalErrorWithThrowable(): void + { + // Arrange + $error = new Exception('fatal error'); + + // Act + $content = $this->formatter->printFatalError('file.css', $error); + + // Assert + $this->assertSame("::error file=file.css::fatal error" . PHP_EOL, $content); + } + + public function testPrintFatalErrorWithoutSource(): void + { + + // Act + $content = $this->formatter->printFatalError(null, 'some error'); + + // Assert + $this->assertSame("::error ::some error" . PHP_EOL, $content); + } + + public function testPrintLintError(): void + { + // Arrange + $positionArr = ['line' => 10, 'column' => 5]; + $lintError = new LintError( + key: LintErrorKey::INVALID_AT_RULE_DECLARATION, + message: 'issue found', + start: new Position($positionArr['line'], $positionArr['column']), + end: new Position($positionArr['line'], $positionArr['column']) + ); + + // Act + $content = $this->formatter->printLintError('file.css', $lintError); + + // Assert + $this->assertSame( + "::error file=file.css,line=10,col=5,endLine=10,endColumn=5::invalid_at_rule_declaration - issue found" . PHP_EOL, + $content + ); + } + + public function testEndLintingOutputsEndGroup(): void + { + // Act + $content = $this->formatter->endLinting('file.css', true); + + // Assert + $this->assertSame( + "::notice ::Success: file.css is valid." . PHP_EOL . + "::endgroup::" . PHP_EOL, + $content + ); + } + + public function testFactoryIntegration(): void + { + $factory = new FormatterFactory(); + $available = $factory->getAvailableFormatters(); + $this->assertContains('github-actions', $available); + + $manager = $factory->create(['github-actions' => null]); + $this->assertInstanceOf(FormatterManager::class, $manager); + } +} diff --git a/tests/TestSuite/Formatter/GitlabCiFormatterTest.php b/tests/TestSuite/Output/Formatter/GitlabCiFormatterTest.php similarity index 63% rename from tests/TestSuite/Formatter/GitlabCiFormatterTest.php rename to tests/TestSuite/Output/Formatter/GitlabCiFormatterTest.php index a1ebb7a..d6c9efd 100644 --- a/tests/TestSuite/Formatter/GitlabCiFormatterTest.php +++ b/tests/TestSuite/Output/Formatter/GitlabCiFormatterTest.php @@ -2,36 +2,44 @@ declare(strict_types=1); -namespace Tests\TestSuite\Formatter; +namespace Tests\TestSuite\Output\Formatter; use PHPUnit\Framework\TestCase; -use CssLint\Formatter\GitlabCiFormatter; +use CssLint\Output\Formatter\GitlabCiFormatter; use CssLint\Position; use CssLint\LintError; use CssLint\LintErrorKey; use Exception; -use RuntimeException; class GitlabCiFormatterTest extends TestCase { + private readonly GitlabCiFormatter $formatter; + + protected function setUp(): void + { + parent::setUp(); + $this->formatter = new GitlabCiFormatter(); + } + public function testGetNameReturnsGitlabCi(): void { - $formatter = new GitlabCiFormatter(); - $this->assertSame('gitlab-ci', $formatter->getName()); + $this->assertSame('gitlab-ci', $this->formatter->getName()); } public function testStartAndEndLintingOutputsEmptyArray(): void { - $formatter = new GitlabCiFormatter(); + // Act + $content = ''; + $content .= $this->formatter->startLinting('file.css'); + $content .= $this->formatter->endLinting('file.css', false); - $this->expectOutputString('[]'); - $formatter->startLinting('file.css'); - $formatter->endLinting('file.css', false); + // Assert + $this->assertSame('[]', $content); } public function testPrintFatalErrorFormatsIssueCorrectly(): void { - $formatter = new GitlabCiFormatter(); + // Arrange $error = new Exception('fatal error'); // Prepare expected issue @@ -56,18 +64,21 @@ public function testPrintFatalErrorFormatsIssueCorrectly(): void ], ]; - $expected = '[' . json_encode($issue) . ']'; + // Act + $content = ''; - $this->expectOutputString($expected); + $content .= $this->formatter->startLinting($path); + $content .= $this->formatter->printFatalError($path, $error); + $content .= $this->formatter->endLinting($path, false); - $formatter->startLinting($path); - $formatter->printFatalError($path, $error); - $formatter->endLinting($path, false); + // Assert + $expected = '[' . json_encode($issue) . ']'; + $this->assertSame($expected, $content); } public function testPrintLintErrorFormatsIssueCorrectly(): void { - $formatter = new GitlabCiFormatter(); + // Arrange $path = 'file.css'; $line = 10; $col = 5; @@ -80,6 +91,15 @@ public function testPrintLintErrorFormatsIssueCorrectly(): void end: new Position($line, $col) ); + // Act + $content = ''; + $content .= $this->formatter->startLinting($path); + $content .= $this->formatter->printLintError($path, $lintError); + $content .= $this->formatter->endLinting($path, false); + + // Assert + $this->assertJson($content, 'Output is not valid JSON'); + // Compute payload and fingerprint $severity = 'major'; $payload = sprintf("%s:%s:%s:%s:%d:%d:%d:%d", $path, $severity, $key->value, $message, $line, $col, $line, $col); @@ -100,30 +120,26 @@ public function testPrintLintErrorFormatsIssueCorrectly(): void ]; $expected = '[' . json_encode($issue) . ']'; - - $this->expectOutputString($expected); - - $formatter->startLinting($path); - $formatter->printLintError($path, $lintError); - $formatter->endLinting($path, false); + $this->assertSame($expected, $content); } public function testDuplicateIssues(): void { - $formatter = new GitlabCiFormatter(); + // Arrange $path = 'file.css'; $error = new Exception('dup'); - $formatter->startLinting($path); + $content = ''; + + $content .= $this->formatter->startLinting($path); // Print the same fatal error twice - $formatter->printFatalError($path, $error); - $formatter->printFatalError($path, $error); - $formatter->endLinting($path, false); + $content .= $this->formatter->printFatalError($path, $error); + $content .= $this->formatter->printFatalError($path, $error); + $content .= $this->formatter->endLinting($path, false); - $output = $this->getActualOutputForAssertion(); - $this->assertJson($output, 'Output is not valid JSON'); - $issues = json_decode($output, true); + $this->assertJson($content, 'Output is not valid JSON'); + $issues = json_decode($content, true); $this->assertCount(2, $issues); // Ensure fingerprints are different diff --git a/tests/TestSuite/Output/Formatter/PlainFormatterTest.php b/tests/TestSuite/Output/Formatter/PlainFormatterTest.php new file mode 100644 index 0000000..fe5714b --- /dev/null +++ b/tests/TestSuite/Output/Formatter/PlainFormatterTest.php @@ -0,0 +1,103 @@ +formatter = new PlainFormatter(); + } + + public function testGetNameReturnsPlain(): void + { + $this->assertSame('plain', $this->formatter->getName()); + } + + public function testStartLintingOutputsCorrectMessage(): void + { + // Act + $content = $this->formatter->startLinting('file.css'); + $this->assertSame("# Lint file.css..." . PHP_EOL, $content); + } + + public function testPrintFatalErrorWithThrowableOutputsColoredMessage(): void + { + // Arrange + $error = new Exception('fatal error'); + + // Act + $content = $this->formatter->printFatalError('file.css', $error); + + // Assert + $this->assertSame("\033[31m/!\ Error: file.css - fatal error\033[0m" . PHP_EOL, $content); + } + + public function testPrintFatalErrorWithStringOutputsColoredMessage(): void + { + // Arrange + $message = 'some error'; + + // Act + $content = $this->formatter->printFatalError('file.css', $message); + + // Assert + $this->assertSame("\033[31m/!\ Error: file.css - some error\033[0m" . PHP_EOL, $content); + } + + public function testPrintLintErrorOutputsColoredMessage(): void + { + // Arrange + $lintError = new LintError( + key: LintErrorKey::INVALID_AT_RULE_DECLARATION, + message: 'issue found', + start: new Position(), + end: new Position() + ); + + // Act + $content = $this->formatter->printLintError('file.css', $lintError); + + // Assert + $this->assertSame( + "\033[31m - [invalid_at_rule_declaration]: issue found (line 1, column 1 to line 1, column 1)\033[0m" . PHP_EOL, + $content + ); + } + + public function testEndLintingOutputsSuccessWhenValid(): void + { + // Act + $content = $this->formatter->endLinting('file.css', true); + + // Assert + $this->assertSame( + "\033[32m => Success: file.css is valid.\033[0m" . PHP_EOL . PHP_EOL, + $content + ); + } + + public function testEndLintingOutputsFailureWhenInvalid(): void + { + // Act + $content = $this->formatter->endLinting('file.css', false); + + // Assert + $this->assertSame( + "\033[31m => Failure: file.css is invalid CSS.\033[0m" . PHP_EOL, + $content + ); + } +} diff --git a/tests/TestSuite/Output/StdoutOutputTest.php b/tests/TestSuite/Output/StdoutOutputTest.php new file mode 100644 index 0000000..768c26e --- /dev/null +++ b/tests/TestSuite/Output/StdoutOutputTest.php @@ -0,0 +1,122 @@ +expectOutputString('Hello, World!'); + $output->write('Hello, World!'); + } + + public function testWriteMultipleContents(): void + { + $output = new StdoutOutput(); + + $this->expectOutputString('First Second Third'); + $output->write('First '); + $output->write('Second '); + $output->write('Third'); + } + + public function testWriteLine(): void + { + $output = new StdoutOutput(); + + $expected = 'Line 1' . PHP_EOL . 'Line 2' . PHP_EOL; + $this->expectOutputString($expected); + + $output->writeLine('Line 1'); + $output->writeLine('Line 2'); + } + + public function testWriteEmptyString(): void + { + $output = new StdoutOutput(); + + $this->expectOutputString(''); + $output->write(''); + } + + public function testWriteLineEmptyString(): void + { + $output = new StdoutOutput(); + + $this->expectOutputString(PHP_EOL); + $output->writeLine(''); + } + + public function testWriteSpecialCharacters(): void + { + $output = new StdoutOutput(); + + $specialContent = "Special chars: \n\t\r àéîöü"; + $this->expectOutputString($specialContent); + $output->write($specialContent); + } + + public function testWriteUnicodeContent(): void + { + $output = new StdoutOutput(); + + $unicodeContent = 'Unicode: 中文 العربية русский 日本語'; + $this->expectOutputString($unicodeContent . PHP_EOL); + $output->writeLine($unicodeContent); + } + + public function testWriteWithControlCharacters(): void + { + $output = new StdoutOutput(); + + $controlContent = "Control chars: \x1B[31mRed\x1B[0m \x1B[32mGreen\x1B[0m"; + $this->expectOutputString($controlContent); + $output->write($controlContent); + } + + public function testCombinedWriteAndWriteLine(): void + { + $output = new StdoutOutput(); + + $expected = 'Start' . 'Middle' . PHP_EOL . 'End'; + $this->expectOutputString($expected); + + $output->write('Start'); + $output->write('Middle'); + $output->writeLine(''); + $output->write('End'); + } + + public function testLargeContent(): void + { + $output = new StdoutOutput(); + + // Test with a large string + $largeContent = str_repeat('Large content line ' . PHP_EOL, 1000); + $this->expectOutputString($largeContent); + $output->write($largeContent); + } + + public function testConsecutiveWrites(): void + { + $output = new StdoutOutput(); + + $expected = ''; + for ($i = 0; $i < 100; $i++) { + $expected .= "Line $i" . PHP_EOL; + } + + $this->expectOutputString($expected); + + for ($i = 0; $i < 100; $i++) { + $output->writeLine("Line $i"); + } + } +}