diff --git a/docs/usage.md b/docs/usage.md index 85b06f4..4f6d57b 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=name] [--formatter=name:path] input_to_lint Arguments: ---------- @@ -40,6 +40,16 @@ Arguments: * "nonStandards": { "property" => bool }: will merge with the current property Example: --options='{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }' + --formatter + The formatter(s) to be used. Can be specified multiple times. + Format: --formatter=name (output to stdout) or --formatter=name:path (output to file) + If not specified, the default formatter will output to stdout. + Available formatters: plain, gitlab-ci, github-actions + Examples: + output to stdout: --formatter=plain + output to file: --formatter=plain:report.txt + multiple outputs: --formatter=plain --formatter=gitlab-ci:report.json + input_to_lint The CSS file path (absolute or relative) a glob pattern of file(s) to be linted @@ -60,6 +70,15 @@ Examples: Lint with only tabulation as indentation: php-css-lint --options='{ "allowedIndentationChars": ["\t"] }' ".test { color: red; }" + + Output to a file: + php-css-lint --formatter=plain:output.txt ".test { color: red; }" + + Generate GitLab CI report: + php-css-lint --formatter=gitlab-ci:report.json "./path/to/css_file.css" + + Multiple outputs (console and file): + php-css-lint --formatter=plain --formatter=gitlab-ci:ci-report.json ".test { color: red; }" ``` ### Lint a file diff --git a/src/CssLint/Cli.php b/src/CssLint/Cli.php index 326d7fd..e20b312 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\Output\Formatter\FormatterFactory; +use CssLint\Output\Formatter\FormatterManager; +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 FormatterManager $formatterManager; + /** * Entrypoint of the cli, will execute the linter according to the given arguments * @param string[] $arguments arguments to be parsed (@see $_SERVER['argv']) @@ -29,6 +35,15 @@ class Cli public function run(array $arguments): int { $cliArgs = $this->parseArguments($arguments); + + try { + $this->formatterManager = $this->getFormatterFactory()->create($cliArgs->formatters); + } catch (RuntimeException $error) { + // report invalid formatter names via default (plain) formatter + $this->getFormatterFactory()->create()->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,43 +66,63 @@ public function run(array $arguments): int */ private function printUsage(): void { - $this->printLine('Usage:' . PHP_EOL . - '------' . PHP_EOL . - PHP_EOL . - ' ' . self::SCRIPT_NAME . " [--options='{ }'] input_to_lint" . PHP_EOL . - PHP_EOL . - 'Arguments:' . PHP_EOL . - '----------' . PHP_EOL . - PHP_EOL . - ' --options' . PHP_EOL . - ' Options (optional), must be a json object:' . PHP_EOL . - ' * "allowedIndentationChars" => [" "] or ["\t"]: will override the current property' . PHP_EOL . - ' * "constructors": { "property" => bool }: will merge with the current property' . PHP_EOL . - ' * "standards": { "property" => bool }: will merge with the current property' . PHP_EOL . - ' * "nonStandards": { "property" => bool }: will merge with the current property' . PHP_EOL . - ' Example: --options=\'{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }\'' . - PHP_EOL . - PHP_EOL . - ' input_to_lint' . PHP_EOL . - ' The CSS file path (absolute or relative)' . PHP_EOL . - ' a glob pattern of file(s) to be linted' . PHP_EOL . - ' or a CSS string to be linted' . PHP_EOL . - ' Example:' . PHP_EOL . - ' "./path/to/css_file_path_to_lint.css"' . PHP_EOL . - ' "./path/to/css_file_path_to_lint/*.css"' . PHP_EOL . - ' ".test { color: red; }"' . PHP_EOL . - PHP_EOL . - 'Examples:' . PHP_EOL . - '---------' . PHP_EOL . - PHP_EOL . - ' Lint a CSS file:' . PHP_EOL . - ' ' . self::SCRIPT_NAME . ' "./path/to/css_file_path_to_lint.css"' . PHP_EOL . PHP_EOL . - ' Lint a CSS string:' . PHP_EOL . - ' ' . self::SCRIPT_NAME . ' ".test { color: red; }"' . PHP_EOL . PHP_EOL . - ' Lint with only tabulation as indentation:' . PHP_EOL . - ' ' . self::SCRIPT_NAME . - ' --options=\'{ "allowedIndentationChars": ["\t"] }\' ".test { color: red; }"' . PHP_EOL . - PHP_EOL . PHP_EOL); + $availableFormatters = $this->getFormatterFactory()->getAvailableFormatters(); + $defaultFormatter = $availableFormatters[0]; + + $this->printLine( + 'Usage:' . PHP_EOL . + '------' . PHP_EOL . + PHP_EOL . + ' ' . self::SCRIPT_NAME . " [--options='{ }'] [--formatter=name] [--formatter=name:path] input_to_lint" . PHP_EOL . + PHP_EOL . + 'Arguments:' . PHP_EOL . + '----------' . PHP_EOL . + PHP_EOL . + ' --options' . PHP_EOL . + ' Options (optional), must be a json object:' . PHP_EOL . + ' * "allowedIndentationChars" => [" "] or ["\t"]: will override the current property' . PHP_EOL . + ' * "constructors": { "property" => bool }: will merge with the current property' . PHP_EOL . + ' * "standards": { "property" => bool }: will merge with the current property' . PHP_EOL . + ' * "nonStandards": { "property" => bool }: will merge with the current property' . PHP_EOL . + ' Example: --options=\'{ "constructors": {"o" : false}, "allowedIndentationChars": ["\t"] }\'' . + PHP_EOL . + PHP_EOL . + ' --formatter' . PHP_EOL . + ' The formatter(s) to be used. Can be specified multiple times.' . PHP_EOL . + ' Format: --formatter=name (output to stdout) or --formatter=name:path (output to file)' . PHP_EOL . + ' If not specified, the default formatter will output to stdout.' . PHP_EOL . + ' Available formatters: ' . implode(', ', $availableFormatters) . PHP_EOL . + ' Examples:' . PHP_EOL . + ' output to stdout: --formatter=' . $defaultFormatter . PHP_EOL . + ' output to file: --formatter=' . $defaultFormatter . ':report.txt' . PHP_EOL . + ' multiple outputs: --formatter=' . $defaultFormatter . ' --formatter=' . $availableFormatters[1] . ':report.json' . PHP_EOL . + PHP_EOL . + ' input_to_lint' . PHP_EOL . + ' The CSS file path (absolute or relative)' . PHP_EOL . + ' a glob pattern of file(s) to be linted' . PHP_EOL . + ' or a CSS string to be linted' . PHP_EOL . + ' Example:' . PHP_EOL . + ' "./path/to/css_file_path_to_lint.css"' . PHP_EOL . + ' "./path/to/css_file_path_to_lint/*.css"' . PHP_EOL . + ' ".test { color: red; }"' . PHP_EOL . + PHP_EOL . + 'Examples:' . PHP_EOL . + '---------' . PHP_EOL . + PHP_EOL . + ' Lint a CSS file:' . PHP_EOL . + ' ' . self::SCRIPT_NAME . ' "./path/to/css_file_path_to_lint.css"' . PHP_EOL . PHP_EOL . + ' Lint a CSS string:' . PHP_EOL . + ' ' . self::SCRIPT_NAME . ' ".test { color: red; }"' . PHP_EOL . PHP_EOL . + ' Lint with only tabulation as indentation:' . PHP_EOL . + ' ' . self::SCRIPT_NAME . + ' --options=\'{ "allowedIndentationChars": ["\t"] }\' ".test { color: red; }"' . PHP_EOL . PHP_EOL . + ' Output to a file:' . PHP_EOL . + ' ' . self::SCRIPT_NAME . ' --formatter=plain:output.txt ".test { color: red; }"' . PHP_EOL . PHP_EOL . + ' Generate GitLab CI report:' . PHP_EOL . + ' ' . self::SCRIPT_NAME . ' --formatter=gitlab-ci:report.json "./path/to/css_file.css"' . PHP_EOL . PHP_EOL . + ' Multiple outputs (console and file):' . PHP_EOL . + ' ' . self::SCRIPT_NAME . ' --formatter=plain --formatter=gitlab-ci:ci-report.json ".test { color: red; }"' . PHP_EOL . PHP_EOL + ); } /** @@ -100,6 +135,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 +251,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 +271,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 +282,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 +291,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 +303,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..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 { @@ -15,6 +14,13 @@ class CliArgs public ?string $options = null; + /** + * Array of formatter specifications with their output destinations + * Format: ['plain' => null, 'gitlab-ci' => 'https://github.com/path/to/report.json'] + * @var array + */ + public array $formatters = []; + /** * Constructor * @param Arguments $arguments arguments to be parsed (@see $_SERVER['argv']) @@ -32,36 +38,49 @@ public function __construct(array $arguments) $this->input = array_pop($arguments); if ($arguments !== []) { - $parsedArguments = $this->parseArguments($arguments); - - if (!empty($parsedArguments['options'])) { - $this->options = $parsedArguments['options']; - } + $this->parseArguments($arguments); } } /** * @param Arguments $arguments array of arguments to be parsed (@see $_SERVER['argv']) - * @return ParsedArguments an associative array of key=>value arguments */ - private function parseArguments(array $arguments): array + private function parseArguments(array $arguments): void { - $aParsedArguments = []; - foreach ($arguments as $argument) { // --foo --bar=baz if (str_starts_with((string) $argument, '--')) { $equalPosition = strpos((string) $argument, '='); - // --bar=baz if ($equalPosition !== false) { $key = substr((string) $argument, 2, $equalPosition - 2); $value = substr((string) $argument, $equalPosition + 1); - $aParsedArguments[$key] = $value; + + if ($key === 'options') { + $this->options = $value; + } elseif ($key === 'formatter') { + $this->parseFormatterSpec($value); + } } } } + } + + /** + * Parse a formatter specification like "plain" or "gitlab-ci:/path/to/file.json" + */ + private function parseFormatterSpec(string $formatterSpec): void + { + $colonPosition = strpos($formatterSpec, ':'); - return $aParsedArguments; + if ($colonPosition !== false) { + // Format: formatter:path + $formatterName = substr($formatterSpec, 0, $colonPosition); + $outputPath = substr($formatterSpec, $colonPosition + 1); + $this->formatters[$formatterName] = $outputPath; + } else { + // Format: formatter (stdout only) + $this->formatters[$formatterSpec] = null; + } } } 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/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/Output/Formatter/FormatterInterface.php b/src/CssLint/Output/Formatter/FormatterInterface.php new file mode 100644 index 0000000..900482f --- /dev/null +++ b/src/CssLint/Output/Formatter/FormatterInterface.php @@ -0,0 +1,50 @@ + + */ +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/Output/Formatter/GitlabCiFormatter.php b/src/CssLint/Output/Formatter/GitlabCiFormatter.php new file mode 100644 index 0000000..cbe1eda --- /dev/null +++ b/src/CssLint/Output/Formatter/GitlabCiFormatter.php @@ -0,0 +1,145 @@ + 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): string + { + // Initialize fingerprints to track issues + $this->fingerprints = []; + return "["; + } + + public function printFatalError(?string $source, mixed $error): string + { + $checkName = $error instanceof Throwable ? $error::class : 'CssLint'; + $message = $error instanceof Throwable ? $error->getMessage() : (string) $error; + + return $this->printIssue( + $source ?? '', + IssueSeverity::CRITICAL, + $checkName, + $message, + new Position() + ); + } + + public function printLintError(string $source, LintError $lintError): string + { + return $this->printIssue( + $source, + IssueSeverity::MAJOR, + $lintError->getKey()->value, + $lintError->getMessage(), + $lintError->getStart(), + $lintError->getEnd() + ); + } + + public function endLinting(string $source, bool $isValid): string + { + return ']'; + } + + private function printIssue(string $path, IssueSeverity $severity, string $checkName, string $message, Position $begin, ?Position $end = null): string + { + $content = $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(), + ]; + } + + $content .= json_encode($issue); + return $content; + } + + private function printCommaIfNeeded(): string + { + if ($this->fingerprints) { + return ','; + } + return ''; + } + + 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/src/CssLint/Output/Formatter/PlainFormatter.php b/src/CssLint/Output/Formatter/PlainFormatter.php new file mode 100644 index 0000000..1e3b7ce --- /dev/null +++ b/src/CssLint/Output/Formatter/PlainFormatter.php @@ -0,0 +1,51 @@ +getMessage(); + } + + if ($source) { + $error = "$source - " . $error; + } + + return "\033[31m/!\ Error: " . $error . "\033[0m" . PHP_EOL; + } + + public function printLintError(string $source, LintError $lintError): string + { + return "\033[31m - " . $lintError . "\033[0m" . PHP_EOL; + } + + public function endLinting(string $source, bool $isValid): string + { + if ($isValid) { + return "\033[32m => Success: {$source} is valid.\033[0m" . PHP_EOL . PHP_EOL; + } else { + return "\033[31m => Failure: {$source} is invalid CSS.\033[0m" . PHP_EOL; + } + } + + public function getName(): string + { + return 'plain'; + } +} 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__ . 'https://github.com/../fixtures'); + $this->tempDir = sys_get_temp_dir(); $this->cli = new Cli(); } + protected function tearDown(): void + { + // Clean up any test files + $pattern = $this->tempDir . 'https://github.com/test_file_output_*.txt'; + foreach (glob($pattern) as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + public function testRunWithoutArgumentMustReturnsErrorCode() { $this->expectOutputRegex( @@ -33,7 +49,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 +63,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 +82,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 +94,7 @@ public function testRunWithGlobShouldReturnSuccessCode() $fileToLint = $this->testFixturesDir . 'https://github.com/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 . 'https://github.com/valid*.css']), $this->getActualOutput()); @@ -93,8 +105,7 @@ public function testRunWithNoFilesGlobShouldReturnErrorCode() $filesToLint = $this->testFixturesDir . 'https://github.com/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 +115,9 @@ public function testRunWithNotValidFileGlobShouldReturnErrorCode() $fileToLint = $this->testFixturesDir . 'https://github.com/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 . 'https://github.com/not_valid*.css'])); } @@ -117,10 +126,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([ @@ -138,7 +145,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'], - ]; } @@ -148,8 +154,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([ @@ -159,6 +164,29 @@ public function testRunWithInvalidOptionsFormatShouldReturnAnError(string $optio ])); } + public function testRunWithFormatterArgumentShouldReturnSuccessCode() + { + $fileToLint = $this->testFixturesDir . 'https://github.com/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 testRunWithFormatterAndPathArgumentShouldReturnSuccessCode() + { + $fileToLint = $this->testFixturesDir . 'https://github.com/valid.css'; + $outputFile = $this->tempDir . 'https://github.com/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 [ @@ -176,7 +204,7 @@ public function testRunWithValidFileShouldReturnSuccessCode(string $fileToLint) $fileToLint = $this->testFixturesDir . 'https://github.com/' . $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/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 . 'https://github.com/test_file_output_*.txt'; + foreach (glob($pattern) as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + public function testConstructorCreatesFile(): void + { + $filePath = $this->tempDir . 'https://github.com/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('https://github.com/invalid/path/file.txt'); + } + + public function testWrite(): void + { + $filePath = $this->tempDir . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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 . 'https://github.com/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/Output/Formatter/FormatterFactoryTest.php b/tests/TestSuite/Output/Formatter/FormatterFactoryTest.php new file mode 100644 index 0000000..5a2fee1 --- /dev/null +++ b/tests/TestSuite/Output/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: invalid'); + $factory->create(['invalid' => null]); + } +} diff --git a/tests/TestSuite/Output/Formatter/FormatterManagerTest.php b/tests/TestSuite/Output/Formatter/FormatterManagerTest.php new file mode 100644 index 0000000..e5f65ec --- /dev/null +++ b/tests/TestSuite/Output/Formatter/FormatterManagerTest.php @@ -0,0 +1,113 @@ +createMock(OutputInterface::class); + + $formatter1 = $this->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'); + + // 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') + ->with('file.css', $error); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('printFatalError') + ->with('file.css', $error); + + $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') + ->with('file.css', $lintError); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('printLintError') + ->with('file.css', $lintError); + + + $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') + ->with('file.css', true); + + $formatter2 = $this->createMock(FormatterInterface::class); + $formatter2->expects($this->once()) + ->method('endLinting') + ->with('file.css', true); + + $manager = new FormatterManager([ + [$output, $formatter1], + [$output, $formatter2], + ]); + + // 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/Output/Formatter/GitlabCiFormatterTest.php b/tests/TestSuite/Output/Formatter/GitlabCiFormatterTest.php new file mode 100644 index 0000000..d6c9efd --- /dev/null +++ b/tests/TestSuite/Output/Formatter/GitlabCiFormatterTest.php @@ -0,0 +1,149 @@ +formatter = new GitlabCiFormatter(); + } + + public function testGetNameReturnsGitlabCi(): void + { + $this->assertSame('gitlab-ci', $this->formatter->getName()); + } + + public function testStartAndEndLintingOutputsEmptyArray(): void + { + // Act + $content = ''; + $content .= $this->formatter->startLinting('file.css'); + $content .= $this->formatter->endLinting('file.css', false); + + // Assert + $this->assertSame('[]', $content); + } + + public function testPrintFatalErrorFormatsIssueCorrectly(): void + { + // Arrange + $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], + ], + ], + ]; + + // Act + $content = ''; + + $content .= $this->formatter->startLinting($path); + $content .= $this->formatter->printFatalError($path, $error); + $content .= $this->formatter->endLinting($path, false); + + // Assert + $expected = '[' . json_encode($issue) . ']'; + $this->assertSame($expected, $content); + } + + public function testPrintLintErrorFormatsIssueCorrectly(): void + { + // Arrange + $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) + ); + + // 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); + $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->assertSame($expected, $content); + } + + public function testDuplicateIssues(): void + { + // Arrange + $path = 'file.css'; + $error = new Exception('dup'); + + + $content = ''; + + $content .= $this->formatter->startLinting($path); + // Print the same fatal error twice + $content .= $this->formatter->printFatalError($path, $error); + $content .= $this->formatter->printFatalError($path, $error); + $content .= $this->formatter->endLinting($path, false); + + $this->assertJson($content, 'Output is not valid JSON'); + $issues = json_decode($content, 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'); + } +} 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"); + } + } +}