From 2c99f379176699c8aaae3680b9f9abad22f49051 Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Wed, 4 Jun 2025 16:49:18 +0200 Subject: [PATCH] feat!: separate parsing from linting Signed-off-by: Emilien Escalle --- .gitignore | 1 + Makefile | 22 +- rector.php | 14 +- scripts/css-referential-generator | 29 ++ scripts/css-referential-scraper | 5 + src/CssLint/CharLinter/CharLinter.php | 12 - src/CssLint/CharLinter/CommentCharLinter.php | 67 --- .../CharLinter/EndOfLineCharLinter.php | 39 -- src/CssLint/CharLinter/ImportCharLinter.php | 49 -- src/CssLint/CharLinter/PropertyCharLinter.php | 110 ----- src/CssLint/CharLinter/SelectorCharLinter.php | 237 ---------- src/CssLint/Cli.php | 80 +++- src/CssLint/LintConfiguration.php | 89 +++- src/CssLint/LintContext.php | 220 --------- src/CssLint/LintError.php | 77 ++++ src/CssLint/LintErrorKey.php | 19 + src/CssLint/Linter.php | 228 +++++---- src/CssLint/Position.php | 49 ++ .../AtRulesPropertiesReferential.php | 31 ++ src/CssLint/Referential/Referential.php | 2 +- .../Standard/AtRulesPropertiesReferential.php | 145 ++++++ src/CssLint/Token/AbstractToken.php | 158 +++++++ src/CssLint/Token/AtRuleToken.php | 74 +++ src/CssLint/Token/BlockToken.php | 55 +++ src/CssLint/Token/CommentToken.php | 29 ++ src/CssLint/Token/PropertyToken.php | 67 +++ src/CssLint/Token/SelectorToken.php | 35 ++ src/CssLint/Token/Token.php | 42 ++ src/CssLint/Token/TokenBoundary.php | 16 + src/CssLint/Token/WhitespaceToken.php | 39 ++ src/CssLint/TokenLinter/AtRuleTokenLinter.php | 210 +++++++++ .../TokenLinter/IndentationTokenLinter.php | 133 ++++++ .../TokenLinter/PropertyTokenLinter.php | 155 +++++++ .../TokenLinter/SelectorTokenLinter.php | 87 ++++ src/CssLint/TokenLinter/TokenError.php | 34 ++ src/CssLint/TokenLinter/TokenLinter.php | 22 + .../Tokenizer/Parser/AbstractParser.php | 99 ++++ src/CssLint/Tokenizer/Parser/AtRuleParser.php | 134 ++++++ src/CssLint/Tokenizer/Parser/BlockParser.php | 130 ++++++ .../Tokenizer/Parser/CommentParser.php | 118 +++++ .../Tokenizer/Parser/EndOfLineParser.php | 51 +++ src/CssLint/Tokenizer/Parser/Parser.php | 29 ++ .../Tokenizer/Parser/PropertyParser.php | 144 ++++++ .../Tokenizer/Parser/SelectorParser.php | 97 ++++ .../Tokenizer/Parser/WhitespaceParser.php | 102 +++++ src/CssLint/Tokenizer/Tokenizer.php | 322 +++++++++++++ src/CssLint/Tokenizer/TokenizerContext.php | 202 ++++++++ .../Downloader/CssReferentialScraper.php | 25 + tests/TestSuite/CliTest.php | 86 ++-- tests/TestSuite/LintConfigurationTest.php | 80 +++- tests/TestSuite/LinterTest.php | 277 ++++++----- tests/TestSuite/TestCase.php | 38 ++ .../TokenLinter/AtRuleTokenLinterTest.php | 136 ++++++ .../IndentationTokenLinterTest.php | 158 +++++++ .../TokenLinter/PropertyTokenLinterTest.php | 312 +++++++++++++ .../TokenLinter/SelectorTokenLinterTest.php | 120 +++++ .../Tokenizer/Parser/AtRuleParserTest.php | 195 ++++++++ .../Tokenizer/Parser/BlockParserTest.php | 184 ++++++++ .../Tokenizer/Parser/CommentParserTest.php | 125 +++++ .../Tokenizer/Parser/EndOfLineParserTest.php | 70 +++ .../Tokenizer/Parser/PropertyParserTest.php | 135 ++++++ .../Tokenizer/Parser/SelectorParserTest.php | 187 ++++++++ .../Tokenizer/Parser/WhitespaceParserTest.php | 79 ++++ .../Tokenizer/TokenizerContextTest.php | 134 ++++++ tests/TestSuite/Tokenizer/TokenizerTest.php | 431 ++++++++++++++++++ tests/fixtures/not_valid.css | 11 - tests/fixtures/valid.css | 11 + tools/composer.json | 6 +- 68 files changed, 5801 insertions(+), 1108 deletions(-) delete mode 100644 src/CssLint/CharLinter/CharLinter.php delete mode 100644 src/CssLint/CharLinter/CommentCharLinter.php delete mode 100644 src/CssLint/CharLinter/EndOfLineCharLinter.php delete mode 100644 src/CssLint/CharLinter/ImportCharLinter.php delete mode 100644 src/CssLint/CharLinter/PropertyCharLinter.php delete mode 100644 src/CssLint/CharLinter/SelectorCharLinter.php delete mode 100644 src/CssLint/LintContext.php create mode 100644 src/CssLint/LintError.php create mode 100644 src/CssLint/LintErrorKey.php create mode 100644 src/CssLint/Position.php create mode 100644 src/CssLint/Referential/NonStandard/AtRulesPropertiesReferential.php create mode 100644 src/CssLint/Referential/Standard/AtRulesPropertiesReferential.php create mode 100644 src/CssLint/Token/AbstractToken.php create mode 100644 src/CssLint/Token/AtRuleToken.php create mode 100644 src/CssLint/Token/BlockToken.php create mode 100644 src/CssLint/Token/CommentToken.php create mode 100644 src/CssLint/Token/PropertyToken.php create mode 100644 src/CssLint/Token/SelectorToken.php create mode 100644 src/CssLint/Token/Token.php create mode 100644 src/CssLint/Token/TokenBoundary.php create mode 100644 src/CssLint/Token/WhitespaceToken.php create mode 100644 src/CssLint/TokenLinter/AtRuleTokenLinter.php create mode 100644 src/CssLint/TokenLinter/IndentationTokenLinter.php create mode 100644 src/CssLint/TokenLinter/PropertyTokenLinter.php create mode 100644 src/CssLint/TokenLinter/SelectorTokenLinter.php create mode 100644 src/CssLint/TokenLinter/TokenError.php create mode 100644 src/CssLint/TokenLinter/TokenLinter.php create mode 100644 src/CssLint/Tokenizer/Parser/AbstractParser.php create mode 100644 src/CssLint/Tokenizer/Parser/AtRuleParser.php create mode 100644 src/CssLint/Tokenizer/Parser/BlockParser.php create mode 100644 src/CssLint/Tokenizer/Parser/CommentParser.php create mode 100644 src/CssLint/Tokenizer/Parser/EndOfLineParser.php create mode 100644 src/CssLint/Tokenizer/Parser/Parser.php create mode 100644 src/CssLint/Tokenizer/Parser/PropertyParser.php create mode 100644 src/CssLint/Tokenizer/Parser/SelectorParser.php create mode 100644 src/CssLint/Tokenizer/Parser/WhitespaceParser.php create mode 100644 src/CssLint/Tokenizer/Tokenizer.php create mode 100644 src/CssLint/Tokenizer/TokenizerContext.php create mode 100644 tests/TestSuite/TestCase.php create mode 100644 tests/TestSuite/TokenLinter/AtRuleTokenLinterTest.php create mode 100644 tests/TestSuite/TokenLinter/IndentationTokenLinterTest.php create mode 100644 tests/TestSuite/TokenLinter/PropertyTokenLinterTest.php create mode 100644 tests/TestSuite/TokenLinter/SelectorTokenLinterTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/AtRuleParserTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/BlockParserTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/CommentParserTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/EndOfLineParserTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/PropertyParserTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/SelectorParserTest.php create mode 100644 tests/TestSuite/Tokenizer/Parser/WhitespaceParserTest.php create mode 100644 tests/TestSuite/Tokenizer/TokenizerContextTest.php create mode 100644 tests/TestSuite/Tokenizer/TokenizerTest.php diff --git a/.gitignore b/.gitignore index a157428..18c77d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.cursor /nbproject/ /vendor /tools/vendor diff --git a/Makefile b/Makefile index 30d1db1..05a2963 100644 --- a/Makefile +++ b/Makefile @@ -25,13 +25,13 @@ shell: ## Execute shell in given PHP version container @$(call run-php,bash) test: ## Execute tests for given PHP version - @$(call run-php,composer test $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer test -- $(filter-out $@,$(MAKECMDGOALS))) -test-update: ## Execute tests and update snapshots for given PHP version - @$(call run-php,composer test:update-snapshot $(filter-out $@,$(MAKECMDGOALS))) +test-ci: ## Execute tests and CI for given PHP version + @$(call run-php,composer test:ci -- $(filter-out $@,$(MAKECMDGOALS))) test-load-fixtures: ## Execute tests and load fixtures for given PHP version - @$(call run-php,composer test:load-fixtures $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer test:load-fixtures -- $(filter-out $@,$(MAKECMDGOALS))) lint: ## Execute lint for given PHP version $(MAKE) php-cs-fixer $(filter-out $@,$(MAKECMDGOALS)) @@ -43,25 +43,25 @@ lint-fix: ## Execute lint fixing for given PHP version $(MAKE) rector-fix $(filter-out $@,$(MAKECMDGOALS)) php-cs-fixer: ## Execute php-cs-fixer for given PHP version - @$(call run-php,composer php-cs-fixer $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer php-cs-fixer -- $(filter-out $@,$(MAKECMDGOALS))) php-cs-fixer-fix: ## Execute php-cs-fixer fixing for given PHP version - @$(call run-php,composer php-cs-fixer:fix $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer php-cs-fixer:fix -- $(filter-out $@,$(MAKECMDGOALS))) rector: ## Execute rector for given PHP version - @$(call run-php,composer rector $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer rector -- $(filter-out $@,$(MAKECMDGOALS))) rector-fix: ## Execute rector fixing for given PHP version - @$(call run-php,composer rector:fix $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer rector:fix -- $(filter-out $@,$(MAKECMDGOALS))) phpstan: ## Execute PHPStan for given PHP version - @$(call run-php,composer phpstan $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer phpstan -- $(filter-out $@,$(MAKECMDGOALS))) ci: ## Execute CI scripts for given PHP version - @$(call run-php,composer ci $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer ci -- $(filter-out $@,$(MAKECMDGOALS))) generate-css-referentials: ## Generate referentials for given PHP version - @$(call run-php,composer generate-css-referentials $(filter-out $@,$(MAKECMDGOALS))) + @$(call run-php,composer generate-css-referentials -- $(filter-out $@,$(MAKECMDGOALS))) ## Run PHP for given version define run-php diff --git a/rector.php b/rector.php index 154ffb4..1fc3d70 100644 --- a/rector.php +++ b/rector.php @@ -14,19 +14,7 @@ ->withAttributesSets( all: true ) - ->withPreparedSets( - deadCode: true, - codeQuality: true, - codingStyle: true, - typeDeclarations: true, - privatization: true, - naming: true, - instanceOf: true, - earlyReturn: true, - strictBooleans: true, - carbon: true, - rectorPreset: true, - phpunitCodeQuality: true, + ->withComposerBased( phpunit: true, ) ->withCache( diff --git a/scripts/css-referential-generator b/scripts/css-referential-generator index db24062..11dee4d 100755 --- a/scripts/css-referential-generator +++ b/scripts/css-referential-generator @@ -73,6 +73,7 @@ $missingNonStandardsAtRules = [ 'theme', 'tailwind' ]; + foreach ($missingNonStandardsAtRules as $atRuleName) { if (isset($standardsAtRules[$atRuleName]) || isset($nonStandardsAtRules[$atRuleName])) { throw new Exception("At-rules $atRuleName already exists in either standards or non-standards at-rules."); @@ -84,3 +85,31 @@ ksort($standardsAtRules); saveReferentialData(CssLint\Referential\Standard\AtRulesReferential::class, $standardsAtRules); ksort($nonStandardsAtRules); saveReferentialData(CssLint\Referential\NonStandard\AtRulesReferential::class, $nonStandardsAtRules); + +$cssAtRulesPropertiesFile = __DIR__ . '/../tests/fixtures/css-at-rules-properties.json'; +$cssAtRulesProperties = json_decode(file_get_contents($cssAtRulesPropertiesFile), true); +$standardsAtRulesProperties = []; +$nonStandardsAtRulesProperties = []; + +foreach ($cssAtRulesProperties as $atRuleName => $atRule) { + foreach ($atRule as $propertyName => $property) { + $isStandard = $property['standard'] ?? false; + if ($isStandard) { + $standardsAtRulesProperties[$atRuleName][$propertyName] = true; + } else { + $nonStandardsAtRulesProperties[$atRuleName][$propertyName] = true; + } + } +} + +ksort($standardsAtRulesProperties); +foreach ($standardsAtRulesProperties as $atRuleName => $properties) { + ksort($standardsAtRulesProperties[$atRuleName]); +} +saveReferentialData(CssLint\Referential\Standard\AtRulesPropertiesReferential::class, $standardsAtRulesProperties); + +ksort($nonStandardsAtRulesProperties); +foreach ($nonStandardsAtRulesProperties as $atRuleName => $properties) { + ksort($nonStandardsAtRulesProperties[$atRuleName]); +} +saveReferentialData(CssLint\Referential\NonStandard\AtRulesPropertiesReferential::class, $nonStandardsAtRulesProperties); diff --git a/scripts/css-referential-scraper b/scripts/css-referential-scraper index 32a6b87..bb7f716 100755 --- a/scripts/css-referential-scraper +++ b/scripts/css-referential-scraper @@ -22,4 +22,9 @@ $cssAtRulesFile = __DIR__ . '/../tests/fixtures/css-at-rules.json'; $scraper->saveToJson($referentials['at-rules'], $cssAtRulesFile); echo "Saved to {$cssAtRulesFile}\n"; +echo "Fetched " . count($referentials['at-rules-properties']) . " at-rule property(s)\n"; +$cssAtRulesPropertiesFile = __DIR__ . '/../tests/fixtures/css-at-rules-properties.json'; +$scraper->saveToJson($referentials['at-rules-properties'], $cssAtRulesPropertiesFile); +echo "Saved to {$cssAtRulesPropertiesFile}\n"; + echo "Done.\n"; diff --git a/src/CssLint/CharLinter/CharLinter.php b/src/CssLint/CharLinter/CharLinter.php deleted file mode 100644 index 70e09e9..0000000 --- a/src/CssLint/CharLinter/CharLinter.php +++ /dev/null @@ -1,12 +0,0 @@ -isComment()) { - if ($this->isCommentEnd($charValue, $lintContext)) { - $lintContext->setComment(false); - } - - $lintContext->setPreviousChar($charValue); - return true; - } - - // First char for a comment - if ($this->isCommentDelimiter($charValue)) { - return true; - } - - // First char for a comment - if ($this->isCommentStart($charValue, $lintContext)) { - // End of comment - $lintContext->setComment(true); - return true; - } - - return null; - } - - /** - * Check if the current char is a comment - */ - private function isCommentDelimiter(string $charValue): bool - { - return $charValue === self::$COMMENT_DELIMITER; - } - - /** - * Check if the current char is the end of a comment - */ - private function isCommentEnd(string $charValue, LintContext $lintContext): bool - { - return $this->isCommentDelimiter($charValue) && $lintContext->assertPreviousChar('*'); - } - - /** - * Check if the current char is the start of a comment - */ - private function isCommentStart(string $charValue, LintContext $lintContext): bool - { - return $charValue === '*' && $lintContext->assertPreviousChar(self::$COMMENT_DELIMITER); - } -} diff --git a/src/CssLint/CharLinter/EndOfLineCharLinter.php b/src/CssLint/CharLinter/EndOfLineCharLinter.php deleted file mode 100644 index 914a801..0000000 --- a/src/CssLint/CharLinter/EndOfLineCharLinter.php +++ /dev/null @@ -1,39 +0,0 @@ -isEndOfLineChar($charValue)) { - $lintContext->setPreviousChar($charValue); - if ($charValue === "\n") { - $lintContext - ->incrementLineNumber() - ->resetCharNumber(); - } - - return true; - } - - return null; - } - - /** - * Check if a given char is an end of line token - * @return boolean : true if the char is an end of line token, else false - */ - protected function isEndOfLineChar(string $charValue): bool - { - return $charValue === "\r" || $charValue === "\n"; - } -} diff --git a/src/CssLint/CharLinter/ImportCharLinter.php b/src/CssLint/CharLinter/ImportCharLinter.php deleted file mode 100644 index af0d4c2..0000000 --- a/src/CssLint/CharLinter/ImportCharLinter.php +++ /dev/null @@ -1,49 +0,0 @@ -isImportStart($charValue, $lintContext)) { - $lintContext->setCurrentContext(LintContextName::CONTEXT_SELECTOR); - $lintContext->appendCurrentContent($charValue); - return true; - } - - if ($this->isImportContext($lintContext)) { - $lintContext->appendCurrentContent($charValue); - - if ($charValue === ';' && $lintContext->assertPreviousChar(')')) { - $lintContext->resetCurrentContext(); - return true; - } - - return true; - } - - return null; - } - - private function isImportStart(string $charValue, LintContext $lintContext): bool - { - return $lintContext->assertCurrentContext(null) && $charValue === self::$IMPORT_RULE[0]; - } - - private function isImportContext(LintContext $lintContext): bool - { - return $lintContext->assertCurrentContext(LintContextName::CONTEXT_SELECTOR) && str_starts_with($lintContext->getCurrentContent(), self::$IMPORT_RULE); - } -} diff --git a/src/CssLint/CharLinter/PropertyCharLinter.php b/src/CssLint/CharLinter/PropertyCharLinter.php deleted file mode 100644 index f77f7eb..0000000 --- a/src/CssLint/CharLinter/PropertyCharLinter.php +++ /dev/null @@ -1,110 +0,0 @@ -lintPropertyNameChar($charValue, $lintContext))) { - return $lintPropertyNameChar; - } - - if (is_bool($lintPropertyContentChar = $this->lintPropertyContentChar($charValue, $lintContext))) { - return $lintPropertyContentChar; - } - - return null; - } - - /** - * Performs lint for a given char, check property name part - * @return bool|null : true if the process should continue, else false, null if this char is not a property name - */ - protected function lintPropertyNameChar(string $charValue, LintContext $lintContext): ?bool - { - if (!$lintContext->assertCurrentContext(LintContextName::CONTEXT_PROPERTY_NAME)) { - return null; - } - - if ($charValue === ':') { - $propertyName = trim($lintContext->getCurrentContent()); - - // Ignore CSS variables (names starting with --) - if (str_starts_with($propertyName, '--')) { - $lintContext->setCurrentContext(LintContextName::CONTEXT_PROPERTY_CONTENT); - return true; - } - - // Check if property name exists - if (!$this->lintConfiguration->propertyExists($propertyName)) { - $lintContext->addError('Unknown CSS property "' . $propertyName . '"'); - } - - $lintContext->setCurrentContext(LintContextName::CONTEXT_PROPERTY_CONTENT); - return true; - } - - $lintContext->appendCurrentContent($charValue); - - if ($charValue === ' ') { - return true; - } - - if (in_array(preg_match('/[-a-zA-Z0-9]+/', $charValue), [0, false], true)) { - $lintContext->addError('Unexpected property name token "' . $charValue . '"'); - } - - return true; - } - - /** - * Performs lint for a given char, check property content part - * @return bool|null : true if the process should continue, else false, null if this char is not a property content - */ - protected function lintPropertyContentChar(string $charValue, LintContext $lintContext): ?bool - { - if (!$lintContext->assertCurrentContext(LintContextName::CONTEXT_PROPERTY_CONTENT)) { - return null; - } - - $lintContext->appendCurrentContent($charValue); - - // End of the property content - if ($charValue === ';' || $charValue === '}') { - // Check if the char is not quoted - $contextContent = $lintContext->getCurrentContent(); - if ((substr_count($contextContent, '"') & 1) === 0 && (substr_count($contextContent, "'") & 1) === 0) { - $lintContext->setCurrentContext(LintContextName::CONTEXT_SELECTOR_CONTENT); - } - - if (trim($contextContent) !== '' && trim($contextContent) !== '0') { - if ($charValue === '}') { - $lintContext->resetCurrentContext(); - } - - return true; - } - - $lintContext->addError('Property cannot be empty'); - return true; - } - - // No property content validation - return true; - } -} diff --git a/src/CssLint/CharLinter/SelectorCharLinter.php b/src/CssLint/CharLinter/SelectorCharLinter.php deleted file mode 100644 index 6161a75..0000000 --- a/src/CssLint/CharLinter/SelectorCharLinter.php +++ /dev/null @@ -1,237 +0,0 @@ -lintSelectorNameChar($charValue, $lintContext))) { - return $lintSelectorChar; - } - - if (is_bool($lintSelectorContentChar = $this->lintSelectorContentChar($charValue, $lintContext))) { - return $lintSelectorContentChar; - } - - if (is_bool($lintNestedSelectorChar = $this->lintNestedSelectorChar($charValue, $lintContext))) { - return $lintNestedSelectorChar; - } - - return null; - } - - /** - * Performs lint for a given char, check selector part - * @return boolean|null : true if the process should continue, else false, null if this char is not about selector - */ - protected function lintSelectorNameChar(string $charValue, LintContext $lintContext): ?bool - { - if ($lintContext->assertCurrentContext(null)) { - return $this->lintSelectorNameStart($charValue, $lintContext); - } - - if (!$lintContext->assertCurrentContext(LintContextName::CONTEXT_SELECTOR)) { - return null; - } - - // A space is valid - if ($charValue === ' ') { - $lintContext->appendCurrentContent($charValue); - return true; - } - - if (is_bool($lintSelectorContentStart = $this->lintSelectorContentStart($charValue, $lintContext))) { - return $lintSelectorContentStart; - } - - return $this->lintSelectorNameContent($charValue, $lintContext); - } - - - /** - * Performs lint for a given char, check selector content part - * @return bool|null : true if the process should continue, else false, null if this char is not a selector content - */ - protected function lintSelectorContentChar(string $charValue, LintContext $lintContext): ?bool - { - if (!$lintContext->assertCurrentContext(LintContextName::CONTEXT_SELECTOR_CONTENT)) { - return null; - } - - $contextContent = $lintContext->getCurrentContent(); - if ( - ($contextContent === '' || $contextContent === '0' || $contextContent === '{') && - $this->lintConfiguration->isAllowedIndentationChar($charValue) - ) { - return true; - } - - if ($charValue === '}') { - $lintContext->resetCurrentContext(); - - return true; - } - - if (preg_match('/[-a-zA-Z]+/', $charValue)) { - $lintContext->setCurrentContext(LintContextName::CONTEXT_PROPERTY_NAME); - $lintContext->appendCurrentContent($charValue); - return true; - } - - return null; - } - - /** - * Performs lint for a given char, check nested selector part - * @return bool|null : true if the process should continue, else false, null if this char is not a nested selector - */ - protected function lintNestedSelectorChar(string $charValue, LintContext $lintContext): ?bool - { - // End of nested selector - if ($lintContext->isNestedSelector() && $lintContext->assertCurrentContext(null) && $charValue === '}') { - $lintContext->decrementNestedSelector(); - return true; - } - - return null; - } - - protected function lintSelectorNameStart(string $charValue, LintContext $lintContext): ?bool - { - if ($this->lintConfiguration->isAllowedIndentationChar($charValue)) { - return true; - } - - if (preg_match('/[@#.a-zA-Z\[\*-:]+/', $charValue)) { - $lintContext->setCurrentContext(LintContextName::CONTEXT_SELECTOR); - $lintContext->appendCurrentContent($charValue); - return true; - } - - return null; - } - - protected function lintSelectorContentStart(string $charValue, LintContext $lintContext): ?bool - { - if ($charValue === ';') { - $this->lintSelectorName($lintContext); - $lintContext->resetCurrentContext(); - return null; - } - - if ($charValue !== '{') { - return null; - } - - $this->lintSelectorName($lintContext); - - // Check if selector is a nested selector - $atRuleSelector = $this->getSelectorNameAtRuleIfexist($lintContext->getCurrentContent()); - if ($atRuleSelector !== null && $atRuleSelector !== '' && $atRuleSelector !== '0') { - $lintContext->incrementNestedSelector(); - $lintContext->resetCurrentContext(); - } else { - $lintContext->setCurrentContext(LintContextName::CONTEXT_SELECTOR_CONTENT); - } - - $lintContext->appendCurrentContent($charValue); - return true; - } - - protected function lintSelectorNameContent(string $charValue, LintContext $lintContext): bool - { - // There cannot have two following commas - if ($charValue === ',') { - $this->lintSelectorName($lintContext); - $selector = $lintContext->getCurrentContent(); - - if ($selector === '' || $selector === '0' || in_array(preg_match('/, *$/', $selector), [0, false], true)) { - $lintContext->appendCurrentContent($charValue); - return true; - } - - $lintContext->addError(sprintf( - 'Selector token %s cannot be preceded by "%s"', - json_encode($charValue), - $selector - )); - return false; - } - - // Wildcard and hash - if (in_array($charValue, ['*', '#'], true)) { - $selector = $lintContext->getCurrentContent(); - if ($selector === '' || $selector === '0' || preg_match('/[a-zA-Z>+,\'\(\):"] *$/', $selector)) { - $lintContext->appendCurrentContent($charValue); - return true; - } - - $lintContext->addError('Selector token "' . $charValue . '" cannot be preceded by "' . $selector . '"'); - return true; - } - - // Dot - if ($charValue === '.') { - $selector = $lintContext->getCurrentContent(); - if ($selector === '' || $selector === '0' || preg_match('/(, |[a-zA-Z]).*$/', $selector)) { - $lintContext->appendCurrentContent($charValue); - return true; - } - - $lintContext->addError('Selector token "' . $charValue . '" cannot be preceded by "' . $selector . '"'); - return true; - } - - if (preg_match('/^[#*.0-9a-zA-Z,:()\[\]="\'-^~_%]+/', $charValue)) { - $lintContext->appendCurrentContent($charValue); - return true; - } - - $lintContext->addError('Unexpected selector token "' . $charValue . '"'); - return true; - } - - protected function lintSelectorName(LintContext $lintContext): void - { - $selector = $lintContext->getCurrentContent(); - - $atRuleSelector = $this->getSelectorNameAtRuleIfexist($selector); - if ($atRuleSelector === null) { - return; - } - - if ($this->lintConfiguration->atRuleExists($atRuleSelector)) { - return; - } - - $lintContext->addError(sprintf( - 'Selector token %s is not a valid at-rule', - json_encode($atRuleSelector) - )); - } - - protected function getSelectorNameAtRuleIfexist(string $selector): ?string - { - if (in_array(preg_match('/^@([a-z]+) .*/', trim($selector), $matches), [0, false], true)) { - // Not an at-rule - return null; - } - - return $matches[1]; - } -} diff --git a/src/CssLint/Cli.php b/src/CssLint/Cli.php index 4bb42c7..326d7fd 100644 --- a/src/CssLint/Cli.php +++ b/src/CssLint/Cli.php @@ -4,11 +4,13 @@ namespace CssLint; +use Generator; use RuntimeException; use Throwable; /** * @phpstan-import-type Errors from \CssLint\Linter + * @phpstan-import-type LintConfigurationOptions from \CssLint\LintConfiguration * @package CssLint */ class Cli @@ -116,6 +118,19 @@ private function getLintConfigurationFromOptions(?string $options): LintConfigur throw new RuntimeException('Unable to parse option argument: ' . $errorMessage); } + $this->assertOptionsAreLintConfiguration($options); + + $lintConfiguration->setOptions($options); + + return $lintConfiguration; + } + + /** + * @param mixed $options + * @phpstan-assert LintConfigurationOptions $options + */ + private function assertOptionsAreLintConfiguration(mixed $options): void + { if (!$options) { throw new RuntimeException('Unable to parse empty option argument'); } @@ -124,9 +139,25 @@ private function getLintConfigurationFromOptions(?string $options): LintConfigur throw new RuntimeException('Unable to parse option argument: must be a json object'); } - $lintConfiguration->setOptions($options); + $allowedKeys = [ + 'allowedIndentationChars', + 'constructors', + 'standards', + 'nonStandards', + ]; + + foreach ($options as $key => $value) { + if (!in_array($key, $allowedKeys)) { + throw new RuntimeException(sprintf('Invalid option key: "%s"', $key)); + } + } - return $lintConfiguration; + // Assert that the allowedIndentationChars is an array of strings + foreach ($allowedKeys as $key) { + if (isset($options[$key]) && !is_array($options[$key])) { + throw new RuntimeException(sprintf('Option "%s" must be an array', $key)); + } + } } private function lintInput(Linter $cssLinter, string $input): int @@ -196,21 +227,16 @@ private function lintGlob(string $glob): int */ private function lintFile(Linter $cssLinter, string $filePath): int { - $this->printLine('# Lint CSS file "' . $filePath . '"...'); + $source = "CSS file \"" . $filePath . "\""; + $this->printLine('# Lint ' . $source . '...'); if (!is_readable($filePath)) { $this->printError('File "' . $filePath . '" is not readable'); return self::RETURN_CODE_ERROR; } - if ($cssLinter->lintFile($filePath)) { - $this->printLine("\033[32m => CSS file \"" . $filePath . "\" is valid\033[0m" . PHP_EOL); - return self::RETURN_CODE_SUCCESS; - } - - $this->printLine("\033[31m => CSS file \"" . $filePath . "\" is not valid:\033[0m" . PHP_EOL); - $this->displayLinterErrors($cssLinter->getErrors()); - return self::RETURN_CODE_ERROR; + $errors = $cssLinter->lintFile($filePath); + return $this->printLinterErrors($source, $errors); } @@ -222,16 +248,10 @@ private function lintFile(Linter $cssLinter, string $filePath): int */ private function lintString(Linter $cssLinter, string $stringValue): int { - $this->printLine('# Lint CSS string...'); - - if ($cssLinter->lintString($stringValue)) { - $this->printLine("\033[32m => CSS string is valid\033[0m" . PHP_EOL); - return self::RETURN_CODE_SUCCESS; - } - - $this->printLine("\033[31m => CSS string is not valid:\033[0m" . PHP_EOL); - $this->displayLinterErrors($cssLinter->getErrors()); - return self::RETURN_CODE_ERROR; + $source = 'CSS string'; + $this->printLine('# Lint ' . $source . '...'); + $errors = $cssLinter->lintString($stringValue); + return $this->printLinterErrors($source, $errors); } /** @@ -245,15 +265,27 @@ private function printError(string $error): void /** * Display the errors returned by the linter - * @param Errors $errors the generated errors to be displayed + * @param Generator $errors the generated errors to be displayed + * @return int the return code related to the execution of the linter */ - private function displayLinterErrors(array $errors): void + private function printLinterErrors(string $source, Generator $errors): int { + $hasErrors = false; foreach ($errors as $error) { + if ($hasErrors === false) { + $this->printLine("\033[31m => " . $source . " is not valid:\033[0m" . PHP_EOL); + $hasErrors = true; + } $this->printLine("\033[31m - " . $error . "\033[0m"); } - $this->printLine(""); + if ($hasErrors) { + $this->printLine(""); + return self::RETURN_CODE_ERROR; + } + + $this->printLine("\033[32m => " . $source . " is valid\033[0m" . PHP_EOL); + return self::RETURN_CODE_SUCCESS; } /** diff --git a/src/CssLint/LintConfiguration.php b/src/CssLint/LintConfiguration.php index c8619c7..9d79c77 100644 --- a/src/CssLint/LintConfiguration.php +++ b/src/CssLint/LintConfiguration.php @@ -9,12 +9,19 @@ use CssLint\Referential\Standard\PropertiesReferential as StandardPropertiesReferential; use CssLint\Referential\NonStandard\AtRulesReferential as NonStandardAtRulesReferential; use CssLint\Referential\Standard\AtRulesReferential as StandardAtRulesReferential; +use CssLint\Referential\NonStandard\AtRulesPropertiesReferential as NonStandardAtRulesPropertiesReferential; +use CssLint\Referential\Standard\AtRulesPropertiesReferential as StandardAtRulesPropertiesReferential; use CssLint\Referential\Referential; +use CssLint\TokenLinter\TokenLinter; +use CssLint\TokenLinter\AtRuleTokenLinter; +use CssLint\TokenLinter\IndentationTokenLinter; +use CssLint\TokenLinter\PropertyTokenLinter; +use CssLint\TokenLinter\SelectorTokenLinter; /** * @phpstan-import-type ReferentialData from Referential * @phpstan-type AllowedIndentationChars array - * @phpstan-type PropertiesOptions array{ + * @phpstan-type LintConfigurationOptions array{ * allowedIndentationChars?: AllowedIndentationChars, * constructors?: ReferentialData, * standards?: ReferentialData, @@ -53,6 +60,18 @@ class LintConfiguration */ protected array $atRulesNonStandards; + /** + * List of standards at-rules properties + * @var ReferentialData + */ + protected array $atRulesPropertiesStandards; + + /** + * List of non standards at-rules properties + * @var ReferentialData + */ + protected array $atRulesPropertiesNonStandards; + /** * List of allowed indentation chars * @var AllowedIndentationChars @@ -66,14 +85,16 @@ public function __construct() $this->propertiesNonStandards = NonStandardPropertiesReferential::getReferential(); $this->atRulesStandards = StandardAtRulesReferential::getReferential(); $this->atRulesNonStandards = NonStandardAtRulesReferential::getReferential(); + $this->atRulesPropertiesStandards = StandardAtRulesPropertiesReferential::getReferential(); + $this->atRulesPropertiesNonStandards = NonStandardAtRulesPropertiesReferential::getReferential(); } /** - * @param PropertiesOptions $options Override default properties - * "allowedIndentationChars" => [" "] or ["\t"]: will override current property - * "constructors": ["property" => bool]: will merge with current property - * "standards": ["property" => bool]: will merge with current property - * "nonStandards": ["property" => bool]: will merge with current property + * @param LintConfigurationOptions $options Override default options + * "allowedIndentationChars" => [" "] or ["\t"] + * "constructors": ["property" => bool] + * "standards": ["property" => bool] + * "nonStandards": ["property" => bool] */ public function setOptions(array $options = []): void { @@ -113,7 +134,7 @@ public function propertyExists(string $property): bool foreach ($allowedConstrutors as $allowedConstrutor) { $propertyWithoutConstructor = preg_replace( - '/^(-' . preg_quote($allowedConstrutor) . '-)/', + '/^(-' . preg_quote($allowedConstrutor, '/') . '-)/', '', $property ); @@ -141,6 +162,24 @@ public function atRuleExists(string $atRule): bool return !empty($this->atRulesNonStandards[$atRule]); } + public function atRuleHasProperties(string $atRule): bool + { + return !empty($this->atRulesPropertiesStandards[$atRule]) || !empty($this->atRulesPropertiesNonStandards[$atRule]); + } + + public function atRulePropertyExists(string $atRule, string $property): bool + { + if (!empty($this->atRulesPropertiesStandards[$atRule][$property])) { + return true; + } + + if (!empty($this->atRulesPropertiesNonStandards[$atRule][$property])) { + return true; + } + + return false; + } + /** * Retrieve indentation chars allowed by the linter * @return AllowedIndentationChars a list of allowed indentation chars @@ -213,4 +252,40 @@ public function mergeAtRulesNonStandards(array $nonStandards): void { $this->atRulesNonStandards = array_merge($this->atRulesNonStandards, $nonStandards); } + + /** + * Merge the given standards at-rules properties with the current ones + * @param ReferentialData $standards the standards at-rules properties to be merged + */ + public function mergeAtRulesPropertiesStandards(array $standards): void + { + /** @var ReferentialData $atRulesPropertiesStandards */ + $atRulesPropertiesStandards = array_merge($this->atRulesPropertiesStandards, $standards); + $this->atRulesPropertiesStandards = $atRulesPropertiesStandards; + } + + /** + * Merge the given non standards at-rules properties with the current ones + * @param ReferentialData $nonStandards non the standards at-rules properties to be merged + */ + public function mergeAtRulesPropertiesNonStandards(array $nonStandards): void + { + /** @var ReferentialData $atRulesPropertiesNonStandards */ + $atRulesPropertiesNonStandards = array_merge($this->atRulesPropertiesNonStandards, $nonStandards); + $this->atRulesPropertiesNonStandards = $atRulesPropertiesNonStandards; + } + + /** + * Get the list of linters + * @return TokenLinter[] + */ + public function getLinters(): array + { + return [ + new AtRuleTokenLinter($this), + new IndentationTokenLinter($this), + new PropertyTokenLinter($this), + new SelectorTokenLinter(), + ]; + } } diff --git a/src/CssLint/LintContext.php b/src/CssLint/LintContext.php deleted file mode 100644 index a931251..0000000 --- a/src/CssLint/LintContext.php +++ /dev/null @@ -1,220 +0,0 @@ - - * @phpstan-type ContextEntry string|null - * @phpstan-type Context ContextEntry|ContextEntry[] - */ -class LintContext -{ - /** - * Errors occurred during the lint process - * @var Errors - */ - protected $errors = []; - - /** - * Current line number - * @var int - */ - protected $lineNumber = 0; - - /** - * Current char number - * @var int - */ - protected $charNumber = 0; - - /** - * Current context name of parsing - */ - private ?LintContextName $lintContextName = null; - - /** - * Current content of parse. Ex: the selector name, the property name or the property content - */ - private string $currentContent = ''; - - /** - * The previous linted char - */ - private ?string $previousChar = null; - - /** - * Tells if the linter is parsing a nested selector. Ex: @media, @keyframes... - */ - private int $nestedSelectorLevel = 0; - - /** - * Tells if the linter is parsing a comment - */ - private bool $comment = false; - - public function getCurrentContext(): ?LintContextName - { - return $this->lintContextName; - } - - /** - * Reset context property - */ - public function resetCurrentContext(): self - { - return $this->setCurrentContext(null); - } - - /** - * Set new context - */ - public function setCurrentContext(?LintContextName $lintContextName): self - { - $this->lintContextName = $lintContextName; - $this->currentContent = ''; - return $this; - } - - /** - * Assert that current context is the same as given - */ - public function assertCurrentContext(?LintContextName $lintContextName): bool - { - return $this->lintContextName === $lintContextName; - } - - /** - * Tells if the linter is parsing a comment - */ - public function isComment(): bool - { - return $this->comment; - } - - /** - * Set the comment flag - */ - public function setComment(bool $comment): void - { - $this->comment = $comment; - } - - /** - * Assert that previous char is the same as given - */ - public function assertPreviousChar(string $charValue): bool - { - return $this->previousChar === $charValue; - } - - /** - * Set new previous char - */ - public function setPreviousChar(string $charValue): self - { - $this->previousChar = $charValue; - return $this; - } - - /** - * Return context content - */ - public function getCurrentContent(): string - { - return $this->currentContent; - } - - /** - * Append new value to context content - */ - public function appendCurrentContent(string $currentContent): self - { - $this->currentContent .= $currentContent; - return $this; - } - - - /** - * Tells if the linter is parsing a nested selector - */ - public function isNestedSelector(): bool - { - return $this->nestedSelectorLevel > 0; - } - - /** - * Increase the nested selector level - */ - public function incrementNestedSelector(): void - { - ++$this->nestedSelectorLevel; - } - - /** - * Decrement the nested selector level - */ - public function decrementNestedSelector(): void - { - if ($this->nestedSelectorLevel > 0) { - --$this->nestedSelectorLevel; - } - } - - - /** - * Reset current char number property - */ - public function resetCharNumber(): self - { - $this->charNumber = 0; - return $this; - } - - /** - * Add 1 to the current line number - */ - public function incrementLineNumber(): self - { - ++$this->lineNumber; - return $this; - } - - /** - * Add 1 to the current char number - */ - public function incrementCharNumber(): self - { - ++$this->charNumber; - return $this; - } - - /** - * Add a new error message to the errors property, it adds extra infos to the given error message - */ - public function addError(string $error): self - { - $this->errors[] = $error . ' (line: ' . $this->lineNumber . ', char: ' . $this->charNumber . ')'; - return $this; - } - - /** - * Return the errors occurred during the lint process - * @return Errors - */ - public function getErrors(): array - { - return $this->errors; - } -} diff --git a/src/CssLint/LintError.php b/src/CssLint/LintError.php new file mode 100644 index 0000000..dabf049 --- /dev/null +++ b/src/CssLint/LintError.php @@ -0,0 +1,77 @@ +key->value, + $this->message, + $this->start->getLine(), + $this->start->getColumn(), + $this->end->getLine(), + $this->end->getColumn() + ); + } + + /** + * Serialize the error to JSON. + * + * @return SerializedLintError + */ + public function jsonSerialize(): array + { + return [ + 'key' => $this->key->value, + 'message' => $this->message, + 'start' => $this->start->jsonSerialize(), + 'end' => $this->end->jsonSerialize(), + ]; + } +} diff --git a/src/CssLint/LintErrorKey.php b/src/CssLint/LintErrorKey.php new file mode 100644 index 0000000..be8f9fa --- /dev/null +++ b/src/CssLint/LintErrorKey.php @@ -0,0 +1,19 @@ +setLintConfiguration($lintConfiguration); } + + if ($tokenizer instanceof Tokenizer) { + $this->tokenizer = $tokenizer; + } } /** @@ -68,164 +65,147 @@ public function setLintConfiguration(LintConfiguration $lintConfiguration): self return $this; } + /** + * Returns an instance of a tokenizer. + * You may need to adjust the class name and constructor as per your project structure. + */ + public function getTokenizer(): Tokenizer + { + if ($this->tokenizer) { + return $this->tokenizer; + } + + return $this->tokenizer = new Tokenizer(); + } + + /** + * Sets an instance of a tokenizer. + * @param Tokenizer $tokenizer + * @return Linter + */ + public function setTokenizer(Tokenizer $tokenizer): self + { + $this->tokenizer = $tokenizer; + return $this; + } + /** * Performs lint on a given string - * @return boolean : true if the string is a valid css string, false else + * @return Generator An array of issues found during linting. */ - public function lintString(string $stringValue): bool + public function lintString(string $stringValue): Generator { - $this->initLint(); - $iIterator = 0; - while (isset($stringValue[$iIterator])) { - if ($this->lintChar($stringValue[$iIterator]) === false) { - return false; - } + $stream = fopen('php://memory', 'r+'); + if ($stream === false) { + throw new RuntimeException('An error occurred while opening a memory stream'); + } - ++$iIterator; + if (fwrite($stream, $stringValue) === false) { + throw new RuntimeException('An error occurred while writing to a memory stream'); } + rewind($stream); - $this->assertLintContextIsClean(); + yield from $this->lintStream($stream); - return $this->getErrors() === []; + return; } /** * Performs lint for a given file path - * @param string $sFilePath : a path of an existing and readable file - * @return boolean : true if the file is a valid css file, else false + * @param string $filePath A path of an existing and readable file + * @return Generator An array of issues found during linting. * @throws InvalidArgumentException * @throws RuntimeException */ - public function lintFile(string $sFilePath): bool + public function lintFile(string $filePath): Generator { - if (!file_exists($sFilePath)) { + if (!file_exists($filePath)) { throw new InvalidArgumentException(sprintf( - 'Argument "$sFilePath" "%s" is not an existing file path', - $sFilePath + 'Argument "$filePath" "%s" is not an existing file path', + $filePath )); } - if (!is_readable($sFilePath)) { + if (!is_readable($filePath)) { throw new InvalidArgumentException(sprintf( - 'Argument "$sFilePath" "%s" is not a readable file path', - $sFilePath + 'Argument "$filePath" "%s" is not a readable file path', + $filePath )); } - $rFileHandle = fopen($sFilePath, 'r'); - if ($rFileHandle === false) { - throw new RuntimeException('An error occurred while opening file "' . $sFilePath . '"'); + $fileHandle = fopen($filePath, 'r'); + if ($fileHandle === false) { + throw new RuntimeException('An error occurred while opening file "' . $filePath . '"'); } - $this->initLint(); + yield from $this->lintStream($fileHandle); - while (($charValue = fgetc($rFileHandle)) !== false) { - if ($this->lintChar($charValue) === false) { - fclose($rFileHandle); - return false; - } - } - - if (!feof($rFileHandle)) { - throw new RuntimeException('An error occurred while reading file "' . $sFilePath . '"'); + if (!feof($fileHandle)) { + throw new RuntimeException('An error occurred while reading file "' . $filePath . '"'); } - fclose($rFileHandle); + fclose($fileHandle); - $this->assertLintContextIsClean(); - - return $this->getErrors() === []; + return; } /** - * Return the errors occurred during the lint process - * @return Errors + * Lint a stream of tokens + * @param resource $stream A valid stream resource + * @return Generator An array of issues found during linting. + * @throws InvalidArgumentException */ - public function getErrors(): array + protected function lintStream($stream): Generator { - return $this->lintContext?->getErrors() ?? []; - } + if (!is_resource($stream)) { + throw new InvalidArgumentException('Argument "$stream" must be a valid resource'); + } - /** - * Initialize linter, reset all process properties - */ - protected function initLint(): static - { - $this - ->resetChartLinters() - ->resetLintContext(); + yield from $this->lintTokens( + $this->getTokenizer()->tokenize($stream) + ); - $this->lintContext?->incrementLineNumber(); - return $this; + return; } /** - * Performs lint on a given char - * @return boolean : true if the process should continue, else false + * Lint a list of tokens or errors + * @param iterable $tokens + * @return Generator An array of issues found during linting. */ - protected function lintChar(string $charValue): ?bool + private function lintTokens(iterable $tokens): Generator { - if (!$this->lintContext instanceof LintContext) { - throw new RuntimeException('Lint context is not initialized'); - } - - $this->lintContext->incrementCharNumber(); - - foreach ($this->charLinters as $charLinter) { - if (is_bool($lintChar = $charLinter->lintChar($charValue, $this->lintContext))) { - $this->lintContext->setPreviousChar($charValue); - return $lintChar; + foreach ($tokens as $tokenOrError) { + if ($tokenOrError instanceof LintError) { + yield $tokenOrError; + continue; } - } - $this->lintContext->addError('Unexpected char ' . json_encode($charValue)); - $this->lintContext->setPreviousChar($charValue); - return false; - } + yield from $this->lintToken($tokenOrError); + } - protected function resetChartLinters(): self - { - $lintConfiguration = $this->getLintConfiguration(); - - $this->charLinters = [ - new EndOfLineCharLinter(), - new CommentCharLinter(), - new ImportCharLinter(), - new SelectorCharLinter($lintConfiguration), - new PropertyCharLinter($lintConfiguration), - ]; - return $this; + return; } - protected function assertLintContextIsClean(): bool + /** + * Lint a token + * @param Token $token + * @return Generator An array of issues found during linting. + */ + private function lintToken(Token $token): Generator { - if (!$this->lintContext instanceof LintContext) { - return true; - } + $linters = $this->getLintConfiguration()->getLinters(); + foreach ($linters as $tokenLinter) { + if (!$tokenLinter->supports($token)) { + continue; + } - $currentContext = $this->lintContext->getCurrentContext(); - if (!$currentContext instanceof LintContextName) { - return true; + yield from $tokenLinter->lint($token); } - - $error = sprintf( - 'Unterminated "%s"', - $currentContext->value, - ); - - $currentContent = $this->lintContext->getCurrentContent(); - if ($currentContent !== '') { - $error .= sprintf(' - "%s"', $currentContent); + $tokenValue = $token->getValue(); + if (is_iterable($tokenValue)) { + yield from $this->lintTokens($tokenValue); } - - $this->lintContext->addError($error); - return false; - } - - protected function resetLintContext(): self - { - $this->lintContext = new LintContext(); - return $this; } } diff --git a/src/CssLint/Position.php b/src/CssLint/Position.php new file mode 100644 index 0000000..83ee0d2 --- /dev/null +++ b/src/CssLint/Position.php @@ -0,0 +1,49 @@ +, column: int<1, max> } + */ +class Position implements JsonSerializable +{ + /** + * @param int<1, max> $line + * @param int<1, max> $column + */ + public function __construct( + private readonly int $line = 1, + private readonly int $column = 1, + ) {} + + /** + * @return int<1, max> + */ + public function getLine(): int + { + return $this->line; + } + + /** + * @return int<1, max> + */ + public function getColumn(): int + { + return $this->column; + } + + /** + * @return SerializedPosition + */ + public function jsonSerialize(): array + { + return [ + 'line' => $this->line, + 'column' => $this->column, + ]; + } +} diff --git a/src/CssLint/Referential/NonStandard/AtRulesPropertiesReferential.php b/src/CssLint/Referential/NonStandard/AtRulesPropertiesReferential.php new file mode 100644 index 0000000..4add657 --- /dev/null +++ b/src/CssLint/Referential/NonStandard/AtRulesPropertiesReferential.php @@ -0,0 +1,31 @@ + + [ + 'font-variant' => true, + ], + 'media' => + [ + '-moz-device-pixel-ratio' => true, + '-webkit-animation' => true, + '-webkit-transform-2d' => true, + '-webkit-transition' => true, + ], + ]; +} diff --git a/src/CssLint/Referential/Referential.php b/src/CssLint/Referential/Referential.php index 045b4d5..dc655b7 100644 --- a/src/CssLint/Referential/Referential.php +++ b/src/CssLint/Referential/Referential.php @@ -5,7 +5,7 @@ namespace CssLint\Referential; /** - * @phpstan-type ReferentialData array + * @phpstan-type ReferentialData array> */ interface Referential { diff --git a/src/CssLint/Referential/Standard/AtRulesPropertiesReferential.php b/src/CssLint/Referential/Standard/AtRulesPropertiesReferential.php new file mode 100644 index 0000000..c63f2bc --- /dev/null +++ b/src/CssLint/Referential/Standard/AtRulesPropertiesReferential.php @@ -0,0 +1,145 @@ + + [ + 'additive-symbols' => true, + 'fallback' => true, + 'negative' => true, + 'pad' => true, + 'prefix' => true, + 'range' => true, + 'speak-as' => true, + 'suffix' => true, + 'symbols' => true, + 'system' => true, + ], + 'font-face' => + [ + 'ascent-override' => true, + 'descent-override' => true, + 'font-display' => true, + 'font-family' => true, + 'font-feature-settings' => true, + 'font-stretch' => true, + 'font-style' => true, + 'font-variation-settings' => true, + 'font-weight' => true, + 'font-width' => true, + 'line-gap-override' => true, + 'size-adjust' => true, + 'src' => true, + 'unicode-range' => true, + ], + 'font-feature-values' => + [ + 'annotation' => true, + 'character-variant' => true, + 'historical-forms' => true, + 'ornaments' => true, + 'styleset' => true, + 'stylistic' => true, + 'swash' => true, + ], + 'font-palette-values' => + [ + 'base-palette' => true, + 'font-family' => true, + 'override-colors' => true, + ], + 'import' => + [ + 'layer' => true, + 'supports' => true, + ], + 'media' => + [ + '-webkit-device-pixel-ratio' => true, + '-webkit-max-device-pixel-ratio' => true, + '-webkit-min-device-pixel-ratio' => true, + '-webkit-transform-3d' => true, + 'any-hover' => true, + 'any-pointer' => true, + 'aspect-ratio' => true, + 'calc' => true, + 'color' => true, + 'color-gamut' => true, + 'color-index' => true, + 'device-aspect-ratio' => true, + 'device-height' => true, + 'device-posture' => true, + 'device-width' => true, + 'display-mode' => true, + 'dynamic-range' => true, + 'forced-colors' => true, + 'grid' => true, + 'height' => true, + 'hover' => true, + 'inverted-colors' => true, + 'monochrome' => true, + 'nested-queries' => true, + 'orientation' => true, + 'overflow-block' => true, + 'overflow-inline' => true, + 'pointer' => true, + 'prefers-color-scheme' => true, + 'prefers-contrast' => true, + 'prefers-reduced-data' => true, + 'prefers-reduced-motion' => true, + 'prefers-reduced-transparency' => true, + 'resolution' => true, + 'scripting' => true, + 'update' => true, + 'video-dynamic-range' => true, + 'width' => true, + ], + 'page' => + [ + 'bottom-center' => true, + 'bottom-left' => true, + 'bottom-left-corner' => true, + 'bottom-right' => true, + 'bottom-right-corner' => true, + 'left-bottom' => true, + 'left-middle' => true, + 'left-top' => true, + 'page-orientation' => true, + 'right-bottom' => true, + 'right-middle' => true, + 'right-top' => true, + 'size' => true, + 'top-center' => true, + 'top-left' => true, + 'top-left-corner' => true, + 'top-right' => true, + 'top-right-corner' => true, + ], + 'property' => + [ + 'inherits' => true, + 'initial-value' => true, + 'syntax' => true, + ], + 'supports' => + [ + 'font-format' => true, + 'font-tech' => true, + 'selector' => true, + ], + ]; +} diff --git a/src/CssLint/Token/AbstractToken.php b/src/CssLint/Token/AbstractToken.php new file mode 100644 index 0000000..0975848 --- /dev/null +++ b/src/CssLint/Token/AbstractToken.php @@ -0,0 +1,158 @@ + + */ +abstract class AbstractToken implements Token +{ + protected ?BlockToken $parent = null; + + protected ?Token $previousToken = null; + + /** + * @param TData $data + */ + public function __construct( + private readonly string $type, + protected mixed $data, + private readonly Position $start, + private ?Position $end = null + ) {} + + public function getType(): string + { + return $this->type; + } + + /** + * @return TValue + */ + abstract public function getValue(): mixed; + + public function getStart(): Position + { + return $this->start; + } + + public function getEnd(): ?Position + { + return $this->end; + } + + /** + * @return self + */ + public function setEnd(Position $end): self + { + $this->end = $end; + return $this; + } + + /** + * Returns a JSON serializable representation of the token. + * + * @return SerializedToken + */ + public function jsonSerialize(): array + { + $value = $this->data; + if ($value instanceof JsonSerializable) { + $value = $value->jsonSerialize(); + } elseif (is_array($value)) { + $value = array_map(fn($item) => $item instanceof JsonSerializable ? $item->jsonSerialize() : $item, $value); + } + + return [ + 'type' => $this->type, + 'value' => $value, + 'start' => $this->start->jsonSerialize(), + 'end' => $this->end?->jsonSerialize(), + ]; + } + + public function isComplete(): bool + { + return $this->end !== null; + } + + /** + * @return self + */ + public function setParent(?BlockToken $parent): self + { + $this->parent = $parent; + return $this; + } + + public function getParent(): ?BlockToken + { + return $this->parent; + } + + public function getPreviousToken(): ?Token + { + return $this->previousToken; + } + + /** + * @return self + */ + public function setPreviousToken(?Token $previousToken): self + { + $this->previousToken = $previousToken; + return $this; + } + + public static function calculateStartPosition(TokenizerContext $tokenizerContext): Position + { + $currentPosition = $tokenizerContext->getCurrentPosition(); + + $startColumn = $currentPosition->getColumn() - strlen($tokenizerContext->getCurrentContent()); + if ($startColumn < 1) { + $startColumn = 1; + } + + return new Position( + $currentPosition->getLine(), + $startColumn + ); + } + + public static function calculateEndPosition(TokenizerContext $tokenizerContext, ?Token $token = null): Position + { + $currentPosition = $tokenizerContext->getCurrentPosition(); + $endColumn = $currentPosition->getColumn(); + + if (is_a(static::class, TokenBoundary::class, true)) { + $endColumn = $endColumn - 1; + } + + if ($token) { + $startPosition = $token->getStart(); + $startLine = $startPosition->getLine(); + $startColumn = $startPosition->getColumn(); + + if ($startLine === $currentPosition->getLine() && $endColumn <= $startColumn) { + $endColumn = $startColumn + 1; + } + } + + if ($endColumn < 1) { + $endColumn = 1; + } + + return new Position($currentPosition->getLine(), $endColumn); + } +} diff --git a/src/CssLint/Token/AtRuleToken.php b/src/CssLint/Token/AtRuleToken.php new file mode 100644 index 0000000..c4e5f1c --- /dev/null +++ b/src/CssLint/Token/AtRuleToken.php @@ -0,0 +1,74 @@ + + */ +class AtRuleToken extends AbstractToken implements TokenBoundary +{ + /** + * Constructs an AtRuleToken. + * + * @param string $name The at-rule name (without the @ symbol) + * @param string|null $value The at-rule value/parameters + * @param Position $start The start position of the at-rule in the source + * @param ?Position $end The end position of the at-rule in the source + */ + public function __construct(string $name, ?string $value, Position $start, ?Position $end = null) + { + parent::__construct('at-rule', ['name' => $name, 'value' => $value, 'isBlock' => false], $start, $end); + } + + /** + * @param class-string $tokenClass + */ + public function canTransitionTo(string $tokenClass, TokenizerContext $tokenizerContext): bool + { + return $this->isBlock() && $tokenClass === BlockToken::class; + } + + /** + * Gets the at-rule name. + */ + public function getName(): string + { + return $this->data['name']; + } + + public function setName(string $name): self + { + $this->data['name'] = $name; + return $this; + } + + /** + * Gets the at-rule value/parameters. + */ + public function getValue(): ?string + { + return $this->data['value']; + } + + public function setValue(?string $value): self + { + $this->data['value'] = $value; + return $this; + } + + public function isBlock(): bool + { + return !!$this->data['isBlock']; + } + + public function setIsBlock(bool $isBlock): self + { + $this->data['isBlock'] = $isBlock; + return $this; + } +} diff --git a/src/CssLint/Token/BlockToken.php b/src/CssLint/Token/BlockToken.php new file mode 100644 index 0000000..2f6fb06 --- /dev/null +++ b/src/CssLint/Token/BlockToken.php @@ -0,0 +1,55 @@ + + */ +class BlockToken extends AbstractToken +{ + /** + * Constructs a BlockToken. + * + * @param Token[] $value The value of the block token, typically an array of properties. + * @param Position $start The start position of the block in the source. + * @param ?Position $end The end position of the block in the source. + */ + public function __construct(array $value, Position $start, ?Position $end = null) + { + parent::__construct('block', $value, $start, $end); + } + + /** + * Get the current token of a given class. + * + * @return Token|null The current token of the given class, or null if not found. + */ + public function getBlockCurrentToken(): ?Token + { + $tokens = $this->getValue(); + $token = $tokens[count($tokens) - 1] ?? null; + + if ($token && !$token->isComplete()) { + return $token; + } + + return null; + } + + public function addToken(Token $token): void + { + $tokens = $this->getValue(); + $tokens[] = $token; + $token->setParent($this); + $this->data = $tokens; + } + + public function getValue(): array + { + return $this->data; + } +} diff --git a/src/CssLint/Token/CommentToken.php b/src/CssLint/Token/CommentToken.php new file mode 100644 index 0000000..8914360 --- /dev/null +++ b/src/CssLint/Token/CommentToken.php @@ -0,0 +1,29 @@ + + */ +class CommentToken extends AbstractToken +{ + public function __construct(string $value, Position $start, ?Position $end = null) + { + parent::__construct('comment', $value, $start, $end); + } + + public function getValue(): string + { + return $this->data; + } + + public function setValue(string $value): self + { + $this->data = $value; + return $this; + } +} diff --git a/src/CssLint/Token/PropertyToken.php b/src/CssLint/Token/PropertyToken.php new file mode 100644 index 0000000..da62837 --- /dev/null +++ b/src/CssLint/Token/PropertyToken.php @@ -0,0 +1,67 @@ + + */ +class PropertyToken extends AbstractToken implements TokenBoundary +{ + /** + * Constructs a PropertyToken. + * + * @param string $name The property name + * @param string $value The property value + * @param Position $start The start position of the property in the source + * @param ?Position $end The end position of the property in the source + */ + public function __construct(string $name, ?string $value, Position $start, ?Position $end = null) + { + parent::__construct('property', ['name' => $name, 'value' => $value], $start, $end); + } + + public function canTransitionTo(string $tokenClass, TokenizerContext $tokenizerContext): bool + { + return $tokenClass === BlockToken::class + && $tokenizerContext->currentContentEndsWith(BlockParser::$BLOCK_END); + } + + /** + * Gets the property name. + */ + public function getName(): string + { + return $this->data['name']; + } + + public function setName(string $name): self + { + $this->data['name'] = $name; + return $this; + } + + /** + * Gets the property value. + */ + public function getValue(): ?string + { + return $this->data['value']; + } + + public function setValue(string $value): self + { + $this->data['value'] = $value; + return $this; + } + + public function isVariable(): bool + { + return str_starts_with($this->getName(), '--'); + } +} diff --git a/src/CssLint/Token/SelectorToken.php b/src/CssLint/Token/SelectorToken.php new file mode 100644 index 0000000..e56cf4e --- /dev/null +++ b/src/CssLint/Token/SelectorToken.php @@ -0,0 +1,35 @@ + + */ +class SelectorToken extends AbstractToken implements TokenBoundary +{ + public function __construct(string $value, Position $start, ?Position $end = null) + { + parent::__construct('selector', $value, $start, $end); + } + + public function canTransitionTo(string $tokenClass, TokenizerContext $tokenizerContext): bool + { + return $tokenClass === BlockToken::class; + } + + public function getValue(): string + { + return $this->data; + } + + public function setValue(string $value): self + { + $this->data = $value; + return $this; + } +} diff --git a/src/CssLint/Token/Token.php b/src/CssLint/Token/Token.php new file mode 100644 index 0000000..399ea0b --- /dev/null +++ b/src/CssLint/Token/Token.php @@ -0,0 +1,42 @@ + $tokenClass + */ + public function canTransitionTo(string $tokenClass, TokenizerContext $tokenizerContext): bool; +} diff --git a/src/CssLint/Token/WhitespaceToken.php b/src/CssLint/Token/WhitespaceToken.php new file mode 100644 index 0000000..6b9f2d4 --- /dev/null +++ b/src/CssLint/Token/WhitespaceToken.php @@ -0,0 +1,39 @@ + + */ +class WhitespaceToken extends AbstractToken implements TokenBoundary +{ + public function __construct(string $value, Position $start, ?Position $end = null) + { + parent::__construct('whitespace', $value, $start, $end); + } + + public function getValue(): string + { + return $this->data; + } + + public function setValue(string $value): self + { + $this->data = $value; + return $this; + } + + /** + * Check if this token can transition to another token type + * @param class-string $tokenClass + */ + public function canTransitionTo(string $tokenClass, TokenizerContext $tokenizerContext): bool + { + return $tokenClass !== WhitespaceToken::class; + } +} diff --git a/src/CssLint/TokenLinter/AtRuleTokenLinter.php b/src/CssLint/TokenLinter/AtRuleTokenLinter.php new file mode 100644 index 0000000..01e84d0 --- /dev/null +++ b/src/CssLint/TokenLinter/AtRuleTokenLinter.php @@ -0,0 +1,210 @@ +getName(); + if (!$name) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'At-rule name is empty', + $token + ); + return; + } + + // Check if at-rule exists + if (!$this->lintConfiguration->atRuleExists($name)) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + sprintf('Unknown at-rule "%s"', $name), + $token + ); + return; + } + + switch ($name) { + case self::$AT_RULE_IMPORT: + yield from $this->validateImportRule($token); + break; + case self::$AT_RULE_CHARSET: + yield from $this->validateCharsetRule($token); + break; + case self::$AT_RULE_LAYER: + yield from $this->validateLayerRule($token); + break; + default: + // No specific validation for other at-rules + break; + } + + return; + } + + /** + * @return Generator + */ + private function validateImportRule(AtRuleToken $token): Generator + { + // Validate at-rule value if present + $value = $token->getValue(); + if ($value === null || trim($value) === '') { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Import value is empty', + $token + ); + return; + } + + // Parse the import value + $parts = preg_split('/\s+/', trim($value), 2); + if ($parts === false) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Invalid import value', + $token + ); + return; + } + + $url = $parts[0]; + $conditions = $parts[1] ?? ''; + + // Validate URL format + if (!$this->isValidImportUrl($url)) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Import URL must be a quoted string or url() function', + $token + ); + return; + } + + // Validate conditions if present + if ($conditions !== '' && !$this->isValidImportConditions($conditions)) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Invalid import conditions. Must be a valid media query, supports() condition, or layer() declaration', + $token + ); + } + + return; + } + + private function isValidImportUrl(string $url): bool + { + // Match either a quoted string or url() function + return preg_match('/^(["\'][^"\']+["\']|url\(["\']?[^"\'\(\)]+["\']?\))$/', $url) === 1; + } + + private function isValidImportConditions(string $conditions): bool + { + // Basic validation for media queries, supports(), and layer() conditions + $validPatterns = [ + // Media types and features + '/^(all|print|screen|speech)(\s+and\s+\([^)]+\))*$/', + // Supports conditions + '/^supports\s*\([^)]+\)$/', + // Layer declaration + '/^layer\s*\([^)]+\)$/', + // Combinations + '/^(layer\s*\([^)]+\)\s+)?((all|print|screen|speech)(\s+and\s+\([^)]+\))*|supports\s*\([^)]+\))$/', + ]; + + foreach ($validPatterns as $pattern) { + if (preg_match($pattern, trim($conditions))) { + return true; + } + } + + return false; + } + + /** + * @return Generator + */ + private function validateCharsetRule(AtRuleToken $token): Generator + { + $value = $token->getValue(); + if ($value === null || !preg_match('/^"[^"]+?"$/', trim($value))) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Charset value must be a quoted string', + $token + ); + } + + return; + } + + /** + * @return Generator + */ + private function validateLayerRule(AtRuleToken $token): Generator + { + $value = $token->getValue(); + if ($value === null || !preg_match(self::$AT_RULE_LAYER_NAME_PATTERN, trim($value))) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + sprintf('Layer value is not valid: "%s"', $value), + $token + ); + return; + } + + // Should not have a comma at the end + if (str_ends_with($value, ',')) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Layer value should not have a comma at the end', + $token + ); + } + + // Should not have consecutive commas + if (str_contains($value, ',,')) { + yield new TokenError( + LintErrorKey::INVALID_AT_RULE_DECLARATION, + 'Layer value should not have consecutive commas', + $token + ); + } + + return; + } +} diff --git a/src/CssLint/TokenLinter/IndentationTokenLinter.php b/src/CssLint/TokenLinter/IndentationTokenLinter.php new file mode 100644 index 0000000..1863546 --- /dev/null +++ b/src/CssLint/TokenLinter/IndentationTokenLinter.php @@ -0,0 +1,133 @@ + A list of issues found during linting. + */ + public function lint(Token $token): Generator + { + if (!$token instanceof WhitespaceToken) { + throw new InvalidArgumentException( + 'IndentationTokenLinter can only lint WhitespaceToken' + ); + } + + // Check indentation character is allowed + yield from $this->checkIndentationCharacter($token); + + return; + } + + /** + * Checks if the linter supports the given token. + * + * @param Token $token The token to check. + * @return bool True if the linter supports the token, false otherwise. + */ + public function supports(Token $token): bool + { + return $token instanceof WhitespaceToken; + } + + /** + * Check if the indentation character is allowed + * @param WhitespaceToken $token The token to check + * @return Generator A list of issues found during linting. + */ + private function checkIndentationCharacter(WhitespaceToken $token): Generator + { + $value = $token->getValue(); + $defaultEndOfLineChar = WhitespaceParser::$END_OF_LINE_CHARS[count(WhitespaceParser::$END_OF_LINE_CHARS) - 1]; + $lines = str_replace(WhitespaceParser::$END_OF_LINE_CHARS, $defaultEndOfLineChar, $value); + $lines = explode($defaultEndOfLineChar, $lines); + $lineNumber = $token->getStart()->getLine(); + + // Get allowed indentation characters from configuration + $allowedChars = $this->lintConfiguration->getAllowedIndentationChars(); + + foreach ($lines as $line) { + $column = 1; + $currentPosition = new Position($lineNumber, $column); + + $currentCharError = null; + /** @var Position|null $currentCharErrorStart */ + $currentCharErrorStart = null; + + // Check each character of indentation + for ($i = 0; $i < strlen($line); $i++) { + $char = $line[$i]; + if (!in_array($char, $allowedChars, true)) { + if ($currentCharError === null) { + $currentCharError = $char; + $currentCharErrorStart = new Position($lineNumber, $column); + } elseif ($currentCharErrorStart && $currentCharError !== $char) { + yield from $this->generateError( + $token, + $currentCharErrorStart, + $currentPosition, + $currentCharError + ); + + $currentCharError = $char; + $currentCharErrorStart = $currentPosition; + } + } + + $column++; + $currentPosition = new Position($lineNumber, $column); + } + + if ($currentCharErrorStart && $currentCharError !== null) { + yield from $this->generateError( + $token, + $currentCharErrorStart, + $currentPosition, + $currentCharError + ); + $currentCharError = null; + $currentCharErrorStart = null; + } + $lineNumber++; + } + + return; + } + + /** + * @return Generator + */ + private function generateError(WhitespaceToken $token, Position $start, Position $end, string $char): Generator + { + yield new TokenError( + LintErrorKey::INVALID_INDENTATION_CHARACTER, + sprintf('Unexpected char "%s"', str_replace(["\t"], ['\t'], $char)), + $token, + $start, + $end + ); + + return; + } +} diff --git a/src/CssLint/TokenLinter/PropertyTokenLinter.php b/src/CssLint/TokenLinter/PropertyTokenLinter.php new file mode 100644 index 0000000..da359e9 --- /dev/null +++ b/src/CssLint/TokenLinter/PropertyTokenLinter.php @@ -0,0 +1,155 @@ + A list of issues found during linting. + */ + public function lint(Token $token): Generator + { + if (!$token instanceof PropertyToken) { + throw new InvalidArgumentException( + 'PropertyTokenLinter can only lint PropertyToken' + ); + } + + yield from $this->lintPropertyName($token); + yield from $this->lintPropertyValue($token); + + return; + } + + /** + * Checks if the linter supports the given token. + * + * @param Token $token The token to check. + * @return bool True if the linter supports the token, false otherwise. + */ + public function supports(Token $token): bool + { + return $token instanceof PropertyToken && $token->isComplete(); + } + + /** + * @return Generator + */ + private function lintPropertyName(PropertyToken $token): Generator + { + /** @var PropertyToken $token */ + $name = $token->getName(); + if (!$name) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + 'Property name is empty', + $token + ); + return; + } + + if ($token->isVariable()) { + // Check the variable format + if (!preg_match(self::VARIABLE_FORMAT, $name)) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + sprintf('Invalid variable format: "%s"', $name), + $token + ); + } + return; + } + + if (!preg_match(self::PROPERTY_NAME_PATTERN, $name)) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + sprintf('Invalid property name format: "%s"', $name), + $token + ); + return; + } + + // Get the parent block token to determine context + $parentBlock = $token->getParent(); + if ($parentBlock === null) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + 'Property must be inside a block', + $token + ); + return; + } + + // Check if we're in an at-rule block + $descriptorToken = $parentBlock->getPreviousToken(); + + if ($descriptorToken instanceof AtRuleToken) { + // Check if property is valid for this at-rule + $atRuleName = $descriptorToken->getName(); + if ($this->lintConfiguration->atRuleHasProperties($atRuleName)) { + if (!$this->lintConfiguration->atRulePropertyExists($atRuleName, $name)) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + sprintf('Property "%s" is not valid in @%s rule', $name, $atRuleName), + $token + ); + } + return; + } + } + + // For regular selector blocks, check standard CSS properties + if (!$this->lintConfiguration->propertyExists($name)) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + sprintf('Unknown property "%s"', $name), + $token + ); + } + + return; + } + + /** + * @return Generator + */ + private function lintPropertyValue(PropertyToken $token): Generator + { + $value = $token->getValue(); + if ($value === null) { + yield new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + 'Property value is empty', + $token + ); + return; + } + + return; + } +} diff --git a/src/CssLint/TokenLinter/SelectorTokenLinter.php b/src/CssLint/TokenLinter/SelectorTokenLinter.php new file mode 100644 index 0000000..a163900 --- /dev/null +++ b/src/CssLint/TokenLinter/SelectorTokenLinter.php @@ -0,0 +1,87 @@ +, +, ~) + */ + public static string $SELECTOR_PATTERN = '/^[.#a-zA-Z0-9\[\]=\'"\-_\:>\+~\s\(\),]+$/'; + + /** + * Lints a token and returns a list of issues found. + * + * @param Token $token The token to lint. + * @return Generator A list of issues found during linting. + */ + public function lint(Token $token): Generator + { + if (!$token instanceof SelectorToken) { + throw new InvalidArgumentException( + 'SelectorTokenLinter can only lint SelectorToken' + ); + } + + $value = $token->getValue(); + + if (!preg_match(self::$SELECTOR_PATTERN, $value)) { + yield new TokenError( + LintErrorKey::UNEXPECTED_SELECTOR_CHARACTER, + sprintf('Selector contains invalid characters: "%s"', $value), + $token + ); + } + + // Check if the selector contains consecutive characters + $notAllowedConsecutiveChars = ['#', '.', '::', '>', '+', '~', ',', '[']; + foreach ($notAllowedConsecutiveChars as $char) { + if (str_contains($value, $char . $char)) { + yield new TokenError( + LintErrorKey::UNEXPECTED_SELECTOR_CHARACTER, + sprintf('Selector contains invalid consecutive characters: "%s"', $value), + $token + ); + } + } + + // Check if selector has the proper number of parentheses + $openParenthesesCount = substr_count($value, '('); + $closeParenthesesCount = substr_count($value, ')'); + if ($openParenthesesCount !== $closeParenthesesCount) { + yield new TokenError( + LintErrorKey::UNEXPECTED_SELECTOR_CHARACTER, + sprintf('Selector contains invalid number of parentheses: "%s"', $value), + $token + ); + } + + return; + } + + /** + * Checks if the linter supports the given token. + * + * @param Token $token The token to check. + * @return bool True if the linter supports the token, false otherwise. + */ + public function supports(Token $token): bool + { + return $token instanceof SelectorToken; + } +} diff --git a/src/CssLint/TokenLinter/TokenError.php b/src/CssLint/TokenLinter/TokenError.php new file mode 100644 index 0000000..16fe6f7 --- /dev/null +++ b/src/CssLint/TokenLinter/TokenError.php @@ -0,0 +1,34 @@ +getType(), $message), + $start ?? $token->getStart(), + $end ?? $token->getEnd() ?? $token->getStart() + ); + } +} diff --git a/src/CssLint/TokenLinter/TokenLinter.php b/src/CssLint/TokenLinter/TokenLinter.php new file mode 100644 index 0000000..9a7c674 --- /dev/null +++ b/src/CssLint/TokenLinter/TokenLinter.php @@ -0,0 +1,22 @@ + A list of issues found during linting. + */ + public function lint(Token $token): Generator; + + public function supports(Token $token): bool; +} diff --git a/src/CssLint/Tokenizer/Parser/AbstractParser.php b/src/CssLint/Tokenizer/Parser/AbstractParser.php new file mode 100644 index 0000000..9ccfab8 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/AbstractParser.php @@ -0,0 +1,99 @@ + + */ +abstract class AbstractParser implements Parser +{ + /** + * @var non-empty-string[] + */ + public static array $END_OF_LINE_CHARS = ["\r\n", "\n"]; + + protected static function removeStartingString(string $content, string $search): string + { + if (str_starts_with($content, $search)) { + return substr($content, strlen($search)); + } + + return $content; + } + + protected static function removeEndingString(string $content, string $search): string + { + if (str_ends_with($content, $search)) { + return substr($content, 0, -strlen($search)); + } + + return $content; + } + + protected function lastCharIsSpace(TokenizerContext $tokenizerContext): bool + { + $lastChar = $tokenizerContext->getLastChar(); + return $lastChar !== null && $this->stringIsSpace($lastChar); + } + + protected function stringIsSpace(string $char): bool + { + return ctype_space($char); + } + + /** + * @param callable(?TToken): (TToken|LintError|null) $generateToken + * @return TToken|LintError|null + */ + protected function handleTokenForCurrentContext(TokenizerContext $tokenizerContext, callable $generateToken): Token|LintError|null + { + $currentParsingToken = $tokenizerContext->getCurrentToken(); + if (!$this->shouldHandleCurrentParsingToken($currentParsingToken)) { + return null; + } + + $tokenOrError = call_user_func($generateToken, $currentParsingToken); + if ($tokenOrError === null || $tokenOrError instanceof LintError) { + return $tokenOrError; + } + + $token = $tokenOrError; + if ($currentParsingToken === null) { + $this->injectTokenIntoCurrentParent($tokenizerContext, $token); + return null; + } + + $end = $token::class::calculateEndPosition($tokenizerContext, $token); + $token->setEnd($end); + + return $token; + } + + /** + * @phpstan-assert-if-true ?TToken $currentParsingToken + */ + private function shouldHandleCurrentParsingToken(?Token $currentParsingToken): bool + { + return $currentParsingToken === null || $currentParsingToken::class === $this->getHandledTokenClass(); + } + + /** + * @param TToken $token + */ + private function injectTokenIntoCurrentParent(TokenizerContext $tokenizerContext, Token $token): void + { + $currentBlockToken = $tokenizerContext->getCurrentBlockToken(); + if ($currentBlockToken !== null) { + $currentBlockToken->addToken($token); + } + + $tokenizerContext->setCurrentToken($token); + } +} diff --git a/src/CssLint/Tokenizer/Parser/AtRuleParser.php b/src/CssLint/Tokenizer/Parser/AtRuleParser.php new file mode 100644 index 0000000..9897012 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/AtRuleParser.php @@ -0,0 +1,134 @@ + + */ +class AtRuleParser extends AbstractParser +{ + /** + * @var non-empty-string + */ + private static string $AT_RULE_START = '@'; + + /** + * @var non-empty-string + */ + private static string $AT_RULE_END = ';'; + + private static string $AT_RULE_PATTERN = '/^@[a-zA-Z0-9-]+/'; + + /** + * Get the token class that this parser handles + * @return class-string + */ + public function getHandledTokenClass(): string + { + return AtRuleToken::class; + } + + /** + * Performs parsing tokenizer current context for at-rules + */ + public function parseCurrentContext(TokenizerContext $tokenizerContext): Token|LintError|null + { + if ($this->lastCharIsSpace($tokenizerContext)) { + return null; + } + + return $this->handleTokenForCurrentContext( + $tokenizerContext, + function (?AtRuleToken $currentAtRuleToken = null) use ($tokenizerContext) { + if (!$currentAtRuleToken) { + if ($this->isAtRule($tokenizerContext)) { + return $this->createAtRuleToken($tokenizerContext); + } + return null; + } + + $currentAtRuleToken = $this->updateAtRuleToken($tokenizerContext, $currentAtRuleToken); + if ($this->isAtRuleEnd($tokenizerContext) || $this->isAtRuleBlockStart($tokenizerContext)) { + return $currentAtRuleToken; + } + return null; + } + ); + } + + private function isAtRule(TokenizerContext $tokenizerContext): bool + { + $currentContent = trim($tokenizerContext->getCurrentContent()); + return preg_match(self::$AT_RULE_PATTERN, $currentContent) === 1; + } + + private function isAtRuleEnd(TokenizerContext $tokenizerContext): bool + { + return $tokenizerContext->currentContentEndsWith(self::$AT_RULE_END); + } + + private function isAtRuleBlockStart(TokenizerContext $tokenizerContext): bool + { + return BlockParser::isBlockStart($tokenizerContext); + } + + private function createAtRuleToken(TokenizerContext $tokenizerContext): AtRuleToken + { + return new ($this->getHandledTokenClass())( + $this->getAtRuleName($tokenizerContext), + null, + AtRuleToken::calculateStartPosition($tokenizerContext), + ); + } + + private function updateAtRuleToken(TokenizerContext $tokenizerContext, AtRuleToken $token): AtRuleToken + { + $name = $this->getAtRuleName($tokenizerContext); + $value = $this->getAtRuleValue($tokenizerContext); + $isBlock = $this->isAtRuleBlockStart($tokenizerContext); + + $token + ->setName($name) + ->setValue($value) + ->setIsBlock($isBlock); + + return $token; + } + + private function getAtRuleName(TokenizerContext $tokenizerContext): string + { + $content = trim($tokenizerContext->getCurrentContent()); + $parts = explode(' ', trim($content), 2); + return trim( + $this->removeStartingString( + $parts[0], + self::$AT_RULE_START + ) + ); + } + + private function getAtRuleValue(TokenizerContext $tokenizerContext): ?string + { + $content = trim($tokenizerContext->getCurrentContent()); + $parts = explode(' ', trim($content), 2); + + if (!isset($parts[1])) { + return null; + } + + $atRuleValue = $parts[1]; + + foreach ([self::$AT_RULE_END, BlockParser::$BLOCK_START] as $endChar) { + $atRuleValue = self::removeEndingString($atRuleValue, $endChar); + } + + return trim($atRuleValue); + } +} diff --git a/src/CssLint/Tokenizer/Parser/BlockParser.php b/src/CssLint/Tokenizer/Parser/BlockParser.php new file mode 100644 index 0000000..364c82a --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/BlockParser.php @@ -0,0 +1,130 @@ + + */ +class BlockParser extends AbstractParser +{ + /** + * @var non-empty-string + */ + public static string $BLOCK_START = '{'; + + /** + * @var non-empty-string + */ + public static string $BLOCK_END = '}'; + + /** + * Check if the current char is the start of a block + */ + public static function isBlockStart(TokenizerContext $tokenizerContext): bool + { + $currentContent = $tokenizerContext->getCurrentContent(); + + // Ensure we have a valid block start + if (!$tokenizerContext->currentContentEndsWith(self::$BLOCK_START)) { + return false; + } + + // Make sure we're not inside a string or comment + $contentBeforeBlock = substr($currentContent, 0, -1); + $openQuotes = substr_count($contentBeforeBlock, '"') + substr_count($contentBeforeBlock, "'"); + if ($openQuotes % 2 !== 0) { + return false; + } + + return true; + } + + /** + * Check if the current char is the end of a block + */ + public static function isBlockEnd(TokenizerContext $tokenizerContext, bool $fullContent = false): bool + { + $value = $fullContent ? trim($tokenizerContext->getCurrentContent()) : $tokenizerContext->getNthLastChars(strlen(self::$BLOCK_END)); + + return $value === self::$BLOCK_END; + } + + public static function getBlockContent(TokenizerContext $tokenizerContext): string + { + $content = trim($tokenizerContext->getCurrentContent()); + $content = self::removeStartingString($content, self::$BLOCK_START); + $content = self::removeEndingString($content, self::$BLOCK_END); + return trim($content); + } + + /** + * Performs parsing tokenizer current context, check block part + */ + public function parseCurrentContext(TokenizerContext $tokenizerContext): BlockToken|null + { + if ($this->lastCharIsSpace($tokenizerContext)) { + return null; + } + + $currentBlockToken = $tokenizerContext->getCurrentBlockToken(); + return $this->handleBlockToken($tokenizerContext, $currentBlockToken); + } + + private function handleBlockToken(TokenizerContext $tokenizerContext, ?BlockToken $currentBlockToken): BlockToken|null + { + if (static::isBlockStart($tokenizerContext)) { + $blockToken = $this->createBlockToken($tokenizerContext); + if ($currentBlockToken === null) { + $tokenizerContext->setCurrentBlockToken($blockToken); + } else { + $currentBlockToken->addToken($blockToken); + $tokenizerContext->setCurrentBlockToken($blockToken); + } + return null; + } + + if (!static::isBlockEnd($tokenizerContext)) { + return null; + } + + if ($currentBlockToken === null) { + return null; + } + + $currentBlockToken = $this->updateBlockToken($tokenizerContext, $currentBlockToken); + $tokenizerContext->setCurrentBlockToken($currentBlockToken->getParent()); + return $currentBlockToken; + } + + /** + * Creates a BlockToken from the current context + */ + private function createBlockToken(TokenizerContext $tokenizerContext): BlockToken + { + $blockToken = new ($this->getHandledTokenClass())( + [], + BlockToken::calculateStartPosition($tokenizerContext), + ); + + $blockToken->setPreviousToken($tokenizerContext->getPreviousToken()); + + return $blockToken; + } + + private function updateBlockToken(TokenizerContext $tokenizerContext, BlockToken $blockToken): BlockToken + { + $blockToken->setEnd(BlockToken::calculateEndPosition($tokenizerContext, $blockToken)); + return $blockToken; + } + + public function getHandledTokenClass(): string + { + return BlockToken::class; + } +} diff --git a/src/CssLint/Tokenizer/Parser/CommentParser.php b/src/CssLint/Tokenizer/Parser/CommentParser.php new file mode 100644 index 0000000..12cbee4 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/CommentParser.php @@ -0,0 +1,118 @@ + + */ +class CommentParser extends AbstractParser +{ + /** + * @var non-empty-string + */ + private static string $COMMENT_DELIMITER_START = '/*'; + + /** + * @var non-empty-string + */ + private static string $COMMENT_START_LINE_CHAR = '*'; + + /** + * @var non-empty-string + */ + private static string $COMMENT_DELIMITER_END = '*/'; + + /** + * Performs parsing tokenizer current context, check comment part + */ + public function parseCurrentContext(TokenizerContext $tokenizerContext): Token|LintError|null + { + if ($this->lastCharIsSpace($tokenizerContext)) { + return null; + } + + return $this->handleTokenForCurrentContext( + $tokenizerContext, + fn(?CommentToken $currentToken = null) => $this->handleCommentToken($tokenizerContext, $currentToken) + ); + } + + private function handleCommentToken(TokenizerContext $tokenizerContext, ?CommentToken $currentToken): ?CommentToken + { + if ($currentToken) { + $currentToken = $this->updateCommentToken($tokenizerContext, $currentToken); + if ($this->isCommentEnd($tokenizerContext)) { + return $currentToken; + } + return null; + } + + if ($this->isCommentStart($tokenizerContext)) { + return $this->createCommentToken($tokenizerContext); + } + + return null; + } + + /** + * Check if the current char is the end of a comment + */ + private function isCommentEnd(TokenizerContext $lintContext): bool + { + return $lintContext->currentContentEndsWith(self::$COMMENT_DELIMITER_END); + } + + /** + * Check if the current char is the start of a comment + */ + private function isCommentStart(TokenizerContext $tokenizerContext): bool + { + return $tokenizerContext->currentContentEndsWith(self::$COMMENT_DELIMITER_START); + } + + private function createCommentToken(TokenizerContext $tokenizerContext): CommentToken + { + $currentContent = $tokenizerContext->getCurrentContent(); + return new ($this->getHandledTokenClass())( + $currentContent, + CommentToken::calculateStartPosition($tokenizerContext), + ); + } + + private function updateCommentToken(TokenizerContext $tokenizerContext, CommentToken $commentToken): CommentToken + { + $commentToken->setValue($this->getCommentValue($tokenizerContext)); + return $commentToken; + } + + private function getCommentValue(TokenizerContext $tokenizerContext): string + { + $commentValue = trim($tokenizerContext->getCurrentContent()); + $commentValue = self::removeStartingString($commentValue, self::$COMMENT_DELIMITER_START); + $commentValue = self::removeEndingString($commentValue, self::$COMMENT_DELIMITER_END); + $commentValue = trim($commentValue); + + foreach (self::$END_OF_LINE_CHARS as $endOfLineChar) { + $commentLines = explode($endOfLineChar, $commentValue); + $commentLines = array_map( + fn(string $line) => trim(self::removeStartingString(trim($line), self::$COMMENT_START_LINE_CHAR)), + $commentLines + ); + $commentValue = implode($endOfLineChar, $commentLines); + } + + return $commentValue; + } + + public function getHandledTokenClass(): string + { + return CommentToken::class; + } +} diff --git a/src/CssLint/Tokenizer/Parser/EndOfLineParser.php b/src/CssLint/Tokenizer/Parser/EndOfLineParser.php new file mode 100644 index 0000000..dc59e19 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/EndOfLineParser.php @@ -0,0 +1,51 @@ + + */ +class EndOfLineParser extends AbstractParser +{ + /** + * @return class-string + */ + public function getHandledTokenClass(): string + { + return WhitespaceToken::class; + } + + /** + * Performs parsing tokenizer current context, check end of line part + */ + public function parseCurrentContext(TokenizerContext $tokenizerContext): Token|LintError|null + { + if ($this->isEndOfLineChar($tokenizerContext)) { + $tokenizerContext->incrementLine(); + } + + return null; + } + + /** + * Check if a given char is an end of line token + * @return boolean : true if the char is an end of line token, else false + */ + private function isEndOfLineChar(TokenizerContext $tokenizerContext): bool + { + foreach (self::$END_OF_LINE_CHARS as $endOfLineChar) { + if ($tokenizerContext->currentContentEndsWith($endOfLineChar)) { + return true; + } + } + + return false; + } +} diff --git a/src/CssLint/Tokenizer/Parser/Parser.php b/src/CssLint/Tokenizer/Parser/Parser.php new file mode 100644 index 0000000..73e4a0a --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/Parser.php @@ -0,0 +1,29 @@ + + */ + public function getHandledTokenClass(): ?string; +} diff --git a/src/CssLint/Tokenizer/Parser/PropertyParser.php b/src/CssLint/Tokenizer/Parser/PropertyParser.php new file mode 100644 index 0000000..194b4e1 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/PropertyParser.php @@ -0,0 +1,144 @@ + + */ +class PropertyParser extends AbstractParser +{ + /** + * CSS property names can include: + * - Letters a-z, A-Z + * - Numbers 0-9 + * - Hyphens and underscores + * - Custom property prefix -- + * @var non-empty-string + */ + private static string $PROPERTY_NAME_PATTERN = '/^-{0,2}[a-zA-Z][a-zA-Z0-9-_]*\s*:$/'; + + /** + * @var non-empty-string + */ + private static string $PROPERTY_SEPARATOR = ':'; + + /** + * @var non-empty-string + */ + private static string $PROPERTY_END = ';'; + + public function parseCurrentContext(TokenizerContext $tokenizerContext): Token|LintError|null + { + if ($this->lastCharIsSpace($tokenizerContext)) { + return null; + } + + // Property token is only valid in a block token context + if ($tokenizerContext->getCurrentBlockToken() === null) { + return null; + } + + return $this->handleTokenForCurrentContext( + $tokenizerContext, + function (?PropertyToken $currentPropertyToken = null) use ($tokenizerContext) { + if ($currentPropertyToken === null) { + if (!$this->isPropertyName($tokenizerContext)) { + return null; + } + + return $this->createPropertyToken($tokenizerContext); + } + + if ($this->isPropertyEnd($tokenizerContext)) { + $currentPropertyToken = $this->updatePropertyToken($tokenizerContext, $currentPropertyToken); + return $currentPropertyToken; + } + + return null; + } + ); + } + + private function isPropertyName(TokenizerContext $tokenizerContext): bool + { + $currentContent = trim($tokenizerContext->getCurrentContent()); + return preg_match(self::$PROPERTY_NAME_PATTERN, $currentContent) === 1; + } + + private function isPropertyEnd(TokenizerContext $tokenizerContext): bool + { + foreach ( + [ + self::$PROPERTY_END, + BlockParser::$BLOCK_END, + ] as $endChar + ) { + if ($tokenizerContext->currentContentEndsWith($endChar)) { + return true; + } + } + + return false; + } + + private function createPropertyToken(TokenizerContext $tokenizerContext): PropertyToken + { + return new ($this->getHandledTokenClass())( + trim($tokenizerContext->getCurrentContent()), + null, + PropertyToken::calculateStartPosition($tokenizerContext), + ); + } + + private function updatePropertyToken(TokenizerContext $tokenizerContext, PropertyToken $propertyToken): PropertyToken|TokenError + { + $currentContent = $tokenizerContext->getCurrentContent(); + + foreach ( + [ + self::$PROPERTY_END, + BlockParser::$BLOCK_END, + ] as $endChar + ) { + $currentContent = self::removeEndingString($currentContent, $endChar); + } + + $parts = array_map( + 'trim', + explode( + self::$PROPERTY_SEPARATOR, + $currentContent, + 2 + ) + ); + + if (count($parts) !== 2) { + return new TokenError( + LintErrorKey::INVALID_PROPERTY_DECLARATION, + sprintf('Invalid property declaration, missing separator: %s', $currentContent), + $propertyToken + ); + } + [$name, $value] = $parts; + + $propertyToken + ->setName($name) + ->setValue($value); + + return $propertyToken; + } + + public function getHandledTokenClass(): string + { + return PropertyToken::class; + } +} diff --git a/src/CssLint/Tokenizer/Parser/SelectorParser.php b/src/CssLint/Tokenizer/Parser/SelectorParser.php new file mode 100644 index 0000000..6035938 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/SelectorParser.php @@ -0,0 +1,97 @@ + + */ +class SelectorParser extends AbstractParser +{ + /** + * Performs parsing tokenizer current context, check comment part + */ + public function parseCurrentContext(TokenizerContext $tokenizerContext): Token|LintError|null + { + if ($this->lastCharIsSpace($tokenizerContext)) { + return null; + } + + $token = $this->handleTokenForCurrentContext( + $tokenizerContext, + fn(?SelectorToken $currentToken = null) => $this->handleSelectorToken($tokenizerContext, $currentToken) + ); + + if (BlockParser::isBlockStart($tokenizerContext)) { + $token = $this->handleTokenForCurrentContext( + $tokenizerContext, + fn(?SelectorToken $currentToken = null) => $this->handleSelectorToken($tokenizerContext, $currentToken) + ); + } + + return $token; + } + + private function handleSelectorToken(TokenizerContext $tokenizerContext, ?SelectorToken $currentToken): ?SelectorToken + { + if ($currentToken) { + $currentToken = $this->updateSelectorToken($tokenizerContext, $currentToken); + + // If we encounter a selector block start, we finalize the current selector token + if (BlockParser::isBlockStart($tokenizerContext)) { + return $currentToken; + } + return null; + } + + if ($this->isSelector($tokenizerContext)) { + return $this->createSelectorToken($tokenizerContext); + } + + return null; + } + + private function isSelector(TokenizerContext $tokenizerContext): bool + { + if (!$tokenizerContext->currentContentEndsWith(BlockParser::$BLOCK_START)) { + return false; + } + + $selectorValue = $this->getSelectorValue($tokenizerContext); + return preg_match(SelectorTokenLinter::$SELECTOR_PATTERN, $selectorValue) === 1; + } + + private function createSelectorToken(TokenizerContext $tokenizerContext): SelectorToken + { + return new ($this->getHandledTokenClass())( + trim($tokenizerContext->getCurrentContent()), + SelectorToken::calculateStartPosition($tokenizerContext), + ); + } + + private function updateSelectorToken(TokenizerContext $tokenizerContext, SelectorToken $selectorToken): SelectorToken + { + // remove last character which is the block start + $selectorToken->setValue($this->getSelectorValue($tokenizerContext)); + return $selectorToken; + } + + private function getSelectorValue(TokenizerContext $tokenizerContext): string + { + $value = $tokenizerContext->getCurrentContent(); + $value = trim(self::removeEndingString($value, BlockParser::$BLOCK_START)); + return $value; + } + + public function getHandledTokenClass(): string + { + return SelectorToken::class; + } +} diff --git a/src/CssLint/Tokenizer/Parser/WhitespaceParser.php b/src/CssLint/Tokenizer/Parser/WhitespaceParser.php new file mode 100644 index 0000000..80afd32 --- /dev/null +++ b/src/CssLint/Tokenizer/Parser/WhitespaceParser.php @@ -0,0 +1,102 @@ + + */ +class WhitespaceParser extends AbstractParser +{ + /** + * @return class-string + */ + public function getHandledTokenClass(): string + { + return WhitespaceToken::class; + } + + /** + * Performs parsing tokenizer current context, check end of line part + */ + public function parseCurrentContext(TokenizerContext $tokenizerContext): WhitespaceToken|LintError|null + { + return $this->handleTokenForCurrentContext( + $tokenizerContext, + fn(?WhitespaceToken $currentWhitespaceToken = null) => $this->handleWhitespaceToken($tokenizerContext, $currentWhitespaceToken) + ); + } + + private function handleWhitespaceToken(TokenizerContext $tokenizerContext, ?WhitespaceToken $currentWhitespaceToken): ?WhitespaceToken + { + if ($currentWhitespaceToken) { + $currentWhitespaceToken = $this->updateWhitespaceToken($tokenizerContext, $currentWhitespaceToken); + // If we encounter an another char than a space, we finalize the current whitespace token + if (!$this->lastCharIsSpace($tokenizerContext)) { + return $currentWhitespaceToken; + } + return null; + } + + // If we're starting a new whitespace sequence + if ($this->isNewLineWhitespace($tokenizerContext)) { + return $this->createWhitespaceToken($tokenizerContext); + } + + return null; + } + + private function isNewLineWhitespace(TokenizerContext $tokenizerContext): bool + { + $content = $tokenizerContext->getCurrentContent(); + + foreach (self::$END_OF_LINE_CHARS as $endOfLineChar) { + if (str_starts_with($content, $endOfLineChar)) { + $content = $this->removeStartingString($content, $endOfLineChar); + if ($content === '') { + return false; + } + if ($this->stringIsSpace($content)) { + return true; + } + } + } + + return false; + } + + private function createWhitespaceToken(TokenizerContext $tokenizerContext): WhitespaceToken + { + $lastChar = $tokenizerContext->getLastChar(); + if ($lastChar === null) { + throw new LogicException('Last char is null'); + } + + return new ($this->getHandledTokenClass())( + $lastChar, + WhitespaceToken::calculateStartPosition($tokenizerContext), + ); + } + + private function updateWhitespaceToken(TokenizerContext $tokenizerContext, WhitespaceToken $currentWhitespaceToken): WhitespaceToken + { + $content = $tokenizerContext->getCurrentContent(); + $content = str_replace(self::$END_OF_LINE_CHARS, '', $content); + + if (!$this->lastCharIsSpace($tokenizerContext)) { + $lastChar = $tokenizerContext->getLastChar(); + if ($lastChar !== null) { + $content = $this->removeEndingString($content, $lastChar); + } + } + $currentWhitespaceToken->setValue($content); + return $currentWhitespaceToken; + } +} diff --git a/src/CssLint/Tokenizer/Tokenizer.php b/src/CssLint/Tokenizer/Tokenizer.php new file mode 100644 index 0000000..a3cdfd9 --- /dev/null +++ b/src/CssLint/Tokenizer/Tokenizer.php @@ -0,0 +1,322 @@ + An array of tokens or issues found during tokenizing. + */ + public function tokenize($stream): Generator + { + $this->resetTokenizerContext(); + + yield from $this->processStreamContent($stream); + + yield from $this->assertTokenizerContextIsClean(); + + return; + } + + private function resetTokenizerContext(): self + { + $this->tokenizerContext = new TokenizerContext(); + return $this; + } + + /** + * Process the content of the stream chunk by chunk. + * + * @param resource $stream + * @return Generator + */ + private function processStreamContent($stream): Generator + { + $buffer = ''; + $position = 0; + + while (!feof($stream)) { + $buffer .= fread($stream, 1024); + + yield from $this->processBuffer($buffer, $position); + + // Remove processed part of the buffer + $buffer = substr($buffer, $position); + $position = 0; + } + + return; + } + + /** + * Process a buffer of characters and generate tokens. + * + * @param string $buffer The buffer to process + * @param int &$position Current position in the buffer, passed by reference + * @return Generator + */ + private function processBuffer(string $buffer, int &$position): Generator + { + $length = strlen($buffer); + + while ($position < $length) { + $currentChar = $buffer[$position]; + + yield from $this->processCharacter($currentChar); + + $this->tokenizerContext->incrementColumn(); + $position++; + } + + return; + } + + /** + * Process a single character and generate tokens if applicable. + * + * @param string $char The character to process + * @return Generator + */ + private function processCharacter(string $char): Generator + { + $this->tokenizerContext->appendCurrentContent($char); + foreach ($this->getParsers() as $parser) { + $currentBlockToken = $this->tokenizerContext->getCurrentBlockToken(); + $generatedToken = null; + foreach ($this->parseCharacter($parser) as $result) { + if ($result instanceof LintError) { + yield $result; + continue; + } + + if (!$result->isComplete()) { + throw new LogicException(sprintf('Token "%s" is not complete', $result::class)); + } + + $generatedToken = $result; + // Do not yield token having a parent as it is already in parent token + if ($result->getParent() === null) { + yield $result; + } + break; + } + + $enterInNewBlock = $this->tokenizerContext->getCurrentBlockToken() !== $currentBlockToken && BlockParser::isBlockStart($this->tokenizerContext); + + // Handle token transitions using TokenBoundary interface + $shouldParseWithAnotherParser = $generatedToken instanceof TokenBoundary && $this->findNextCompatibleParser($generatedToken) !== null; + + // Token has been generated, reset current content + if ($generatedToken || $enterInNewBlock) { + $this->tokenizerContext->resetCurrentContent(); + } + + if (!$generatedToken) { + continue; + } + + // Token has been generated, and it is the current token + $generatedTokenIsCurrentToken = $generatedToken === $this->tokenizerContext->getCurrentToken(); + if ($generatedTokenIsCurrentToken) { + $this->tokenizerContext->resetCurrentToken(); + } + + + if ($shouldParseWithAnotherParser) { + $this->tokenizerContext->appendCurrentContent($char); + } else { + break; + } + } + + return; + } + + /** + * Find the next parser that can handle tokens that the current token can transition to + */ + private function findNextCompatibleParser(TokenBoundary $token): ?Parser + { + foreach ($this->getParsers() as $parser) { + $handledTokenClass = $parser->getHandledTokenClass(); + if ( + $handledTokenClass + && $token->canTransitionTo($handledTokenClass, $this->tokenizerContext) + ) { + return $parser; + } + } + return null; + } + + /** + * Parses the current context of the tokenizer and yields tokens or lint errors. + * @return Generator + */ + private function parseCharacter(Parser $parser): Generator + { + $result = $parser->parseCurrentContext($this->tokenizerContext); + if (!$result) { + return; + } + + if ($result instanceof BlockToken) { + yield from $this->assertBlockTokenIsClean($result); + } + + yield $result; + + return; + } + + /** + * Returns the list of parsers used to handle different CSS chars. + * + * @return Parser[] The list of parsers. + */ + private function getParsers(): array + { + if (empty($this->parsers)) { + $this->parsers = [ + new EndOfLineParser(), + new CommentParser(), + new AtRuleParser(), + new SelectorParser(), + new PropertyParser(), + new BlockParser(), + new WhitespaceParser(), + ]; + } + + return $this->parsers; + } + + /** + * Assert that the tokenizer context is clean, meaning that the current token is closed. + * Yields the current token if it is a block token for linting purposes. + * @return Generator + */ + private function assertTokenizerContextIsClean(): Generator + { + $currentToken = $this->tokenizerContext->getCurrentToken(); + + if ($currentToken !== null && !$currentToken->isComplete()) { + $currentToken->setEnd( + ($currentToken::class)::calculateEndPosition( + $this->tokenizerContext, + $currentToken + ) + ); + + if (!$currentToken instanceof WhitespaceToken) { + $value = $currentToken->getValue(); + if (is_array($value)) { + $value = json_encode($value); + } + + yield new TokenError( + LintErrorKey::UNCLOSED_TOKEN, + sprintf('Unclosed "%s" detected', $currentToken->getType()), + $currentToken + ); + } + } + + $currentBlockToken = $this->tokenizerContext->getCurrentBlockToken(); + if ($currentBlockToken !== null) { + if (!$currentBlockToken->isComplete()) { + yield $currentBlockToken; + } + yield from $this->assertBlockTokenIsClean($currentBlockToken); + } + + $currentContent = trim($this->tokenizerContext->getCurrentContent()); + if ($currentContent !== '') { + yield LintError::fromTokenizerContext( + LintErrorKey::UNEXPECTED_CHARACTER_END_OF_CONTENT, + sprintf('Unexpected character at end of content: "%s"', $currentContent), + $this->tokenizerContext + ); + } + + return null; + } + + /** + * Assert that the block token is clean, meaning that it has a property token. + * @return Generator + */ + private function assertBlockTokenIsClean(BlockToken $blockToken): Generator + { + $end = $blockToken->getEnd() ?? $this->tokenizerContext->getCurrentPosition(); + $blockTokenTokens = $blockToken->getValue(); + foreach ($blockTokenTokens as $token) { + $isLastWhitespaceToken = $token instanceof WhitespaceToken && $token === $blockTokenTokens[count($blockTokenTokens) - 1]; + if (!$token->isComplete() && !$isLastWhitespaceToken) { + $token->setEnd($end); + yield new TokenError( + LintErrorKey::UNCLOSED_TOKEN, + sprintf('Unclosed "%s" detected', $token->getType()), + $token, + ); + } + + if ($token instanceof BlockToken) { + yield from $this->assertBlockTokenIsClean($token); + } + } + + if (!$blockToken->isComplete()) { + $blockToken->setEnd($end); + yield new TokenError( + LintErrorKey::UNCLOSED_TOKEN, + sprintf('Unclosed "%s" detected', $blockToken->getType()), + $blockToken, + ); + return; + } + + $blockContent = BlockParser::getBlockContent($this->tokenizerContext); + + if ($blockContent !== '' && !BlockParser::isBlockEnd($this->tokenizerContext, true)) { + yield new TokenError( + LintErrorKey::UNEXPECTED_CHARACTER_IN_BLOCK_CONTENT, + sprintf('Unexpected character: "%s"', $blockContent), + $blockToken, + ); + } + + return; + } +} diff --git a/src/CssLint/Tokenizer/TokenizerContext.php b/src/CssLint/Tokenizer/TokenizerContext.php new file mode 100644 index 0000000..108e49c --- /dev/null +++ b/src/CssLint/Tokenizer/TokenizerContext.php @@ -0,0 +1,202 @@ + + */ +class TokenizerContext +{ + /** + * Current content of parse. Ex: the selector name, the property name or the property content + */ + private string $currentContent = ''; + + /** + * Current position of the tokenizer + */ + private ?Position $currentPosition = null; + + /** + * Current token being processed + */ + private ?Token $currentToken = null; + + /** + * Previous token being processed + */ + private ?Token $previousToken = null; + + /** + * Current block token being processed + */ + private ?BlockToken $currentBlockToken = null; + + /** + * Return context content + */ + public function getCurrentContent(): string + { + return $this->currentContent; + } + + /** + * Append new value to context content + */ + public function appendCurrentContent(string $currentContent): self + { + $this->currentContent .= $currentContent; + return $this; + } + + /** + * Reset current content property + */ + public function resetCurrentContent(): self + { + $this->currentContent = ''; + return $this; + } + + /** + * Get the last char of the current content + */ + public function getLastChar(): ?string + { + return $this->getNthLastChars(1); + } + + /** + * Get the nth last char of the current content + * @param int<1, max> $length + * @param int<0, max> $offset + */ + public function getNthLastChars(int $length, int $offset = 0): ?string + { + if (!$this->currentContent) { + return null; + } + + $contentLength = strlen($this->currentContent); + + $offset = $contentLength - $offset - $length; + + if ($offset < 0) { + return null; + } + + return substr($this->currentContent, $offset, $length); + } + + public function currentContentEndsWith(string $string): bool + { + return str_ends_with($this->currentContent, $string); + } + + public function getCurrentPosition(): Position + { + if ($this->currentPosition === null) { + $this->currentPosition = new Position(); + } + + return $this->currentPosition; + } + + public function incrementColumn(): self + { + $currentPosition = $this->getCurrentPosition(); + + $this->currentPosition = new Position( + $currentPosition->getLine(), + $currentPosition->getColumn() + 1, + ); + return $this; + } + + public function incrementLine(): self + { + $currentPosition = $this->getCurrentPosition(); + + $this->currentPosition = new Position( + $currentPosition->getLine() + 1 + ); + return $this; + } + + /** + * Get the current token being processed + */ + public function getCurrentToken(): ?Token + { + return $this->currentToken; + } + + /** + * Reset current token property + */ + public function resetCurrentToken(): self + { + return $this->setCurrentToken(null); + } + + /** + * Set new current token + */ + public function setCurrentToken(?Token $currentToken): self + { + $this->previousToken = $this->currentToken; + $this->currentToken = $currentToken; + return $this; + } + + /** + * Assert that current token is the same type as given token + * @param class-string|null $token + * @phpstan-assert-if-true Token $this->currentToken + * @return bool + */ + public function assertCurrentToken(?string $token): bool + { + if ($token === null) { + return $this->currentToken === null; + } + + if ($this->currentToken === null) { + return false; + } + + return $this->currentToken::class === $token; + } + + public function getPreviousToken(): ?Token + { + return $this->previousToken; + } + + public function getCurrentBlockToken(): ?BlockToken + { + return $this->currentBlockToken; + } + + public function setCurrentBlockToken(?BlockToken $currentBlockToken): self + { + $this->currentBlockToken = $currentBlockToken; + return $this; + } +} diff --git a/tests/Fixtures/Downloader/CssReferentialScraper.php b/tests/Fixtures/Downloader/CssReferentialScraper.php index 29f17f0..3e7cd0e 100644 --- a/tests/Fixtures/Downloader/CssReferentialScraper.php +++ b/tests/Fixtures/Downloader/CssReferentialScraper.php @@ -51,9 +51,21 @@ public function fetchReferentials(): array } } + $atRulesProperties = $w3cReferencial['at-rules-properties'] ?? []; + foreach ($mdnReferencial['at-rules-properties'] as $property => $info) { + if (!isset($atRulesProperties[$property])) { + $atRulesProperties[$property] = $info; + continue; + } + if (!$atRulesProperties[$property]['standard'] && $info['standard']) { + $atRulesProperties[$property] = $info; + } + } + return [ 'properties' => $properties, 'at-rules' => $atRules, + 'at-rules-properties' => $atRulesProperties, ]; } @@ -85,15 +97,28 @@ function isStandard(array $info): bool } $atRules = []; + $atRulesProperties = []; foreach ($data['css']['at-rules'] as $atRule => $info) { $atRules[$atRule] = [ 'standard' => isStandard($info), ]; + + $atRulesProperties[$atRule] = []; + + foreach ($info as $property => $propertyInfo) { + // Get kebab case properties only + if (preg_match('/^[a-z0-9-]+$/', $property) && !isset($atRulesProperties[$property])) { + $atRulesProperties[$atRule][$property] = [ + 'standard' => isStandard($propertyInfo), + ]; + } + } } return [ 'properties' => $properties, 'at-rules' => $atRules, + 'at-rules-properties' => $atRulesProperties, ]; } diff --git a/tests/TestSuite/CliTest.php b/tests/TestSuite/CliTest.php index 1a43396..999fb88 100644 --- a/tests/TestSuite/CliTest.php +++ b/tests/TestSuite/CliTest.php @@ -7,7 +7,7 @@ class CliTest extends TestCase { - private $testFixturesDir; + private string $testFixturesDir; /** * @var Cli @@ -49,24 +49,17 @@ public function testRunWithNotValidStringShouldReturnErrorCode() '# Lint CSS string...' . PHP_EOL . "\033[31m => CSS string is not valid:\033[0m" . PHP_EOL . PHP_EOL . - "\033[31m - Unknown CSS property \"displady\" (line: 1, char: 17)\033[0m" . PHP_EOL . - "\033[31m - Unexpected char \":\" (line: 3, char: 13)\033[0m" . 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 ); - $this->assertEquals(1, $this->cli->run(['php-css-lint', '.test { displady: block; - width: 0; - : }'])); - } - public function testRunWithValidFileShouldReturnSuccessCode() - { - $fileToLint = $this->testFixturesDir . '/valid.css'; - $this->expectOutputString( - "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . - "\033[32m => CSS file \"$fileToLint\" is valid\033[0m" . PHP_EOL . - PHP_EOL - ); - $this->assertEquals(0, $this->cli->run(['php-css-lint', $fileToLint]), $this->getActualOutput()); + $this->assertEquals(1, $this->cli->run([ + 'php-css-lint', + '.test { displady: block; + width: 0; + : }', + ])); } public function testRunWithNotValidFileShouldReturnErrorCode() @@ -77,8 +70,8 @@ public function testRunWithNotValidFileShouldReturnErrorCode() "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . "\033[31m => CSS file \"$fileToLint\" is not valid:\033[0m" . PHP_EOL . PHP_EOL . - "\033[31m - Unknown CSS property \"bordr-top-style\" (line: 8, char: 20)\033[0m" . PHP_EOL . - "\033[31m - Unterminated \"selector content\" (line: 17, char: 0)\033[0m" . 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 ); $this->assertEquals(1, $this->cli->run(['php-css-lint', $fileToLint])); @@ -92,7 +85,7 @@ public function testRunWithGlobShouldReturnSuccessCode() "\033[32m => 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->assertEquals(0, $this->cli->run(['php-css-lint', $this->testFixturesDir . '/valid*.css']), $this->getActualOutput()); } public function testRunWithNoFilesGlobShouldReturnErrorCode() @@ -106,7 +99,6 @@ public function testRunWithNoFilesGlobShouldReturnErrorCode() $this->assertEquals(1, $this->cli->run(['php-css-lint', $filesToLint])); } - public function testRunWithNotValidFileGlobShouldReturnErrorCode() { $fileToLint = $this->testFixturesDir . '/not_valid.css'; @@ -114,8 +106,8 @@ public function testRunWithNotValidFileGlobShouldReturnErrorCode() "# Lint CSS file \"$fileToLint\"..." . PHP_EOL . "\033[31m => CSS file \"$fileToLint\" is not valid:\033[0m" . PHP_EOL . PHP_EOL . - "\033[31m - Unknown CSS property \"bordr-top-style\" (line: 8, char: 20)\033[0m" . PHP_EOL . - "\033[31m - Unterminated \"selector content\" (line: 17, char: 0)\033[0m" . 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 ); $this->assertEquals(1, $this->cli->run(['php-css-lint', $this->testFixturesDir . '/not_valid*.css'])); @@ -127,26 +119,66 @@ public function testRunWithOptionsMustBeUsedByTheLinter() "# Lint CSS string..." . PHP_EOL . "\033[31m => CSS string is not valid:\033[0m" . PHP_EOL . PHP_EOL . - "\033[31m - Unexpected char \" \" (line: 1, char: 8)\033[0m" . 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 ); + $this->assertEquals(1, $this->cli->run([ 'php-css-lint', '--options={ "allowedIndentationChars": ["\t"] }', - '.test { display: block; }', + ".test {\n display: block; }", ])); } - public function testRunWithInvalidOptionsFormatShouldReturnAnError() + public function unvalidOptionsProvider() + { + return [ + 'invalid json' => ['{ "allowedIndentationChars": }', 'Unable to parse option argument: Syntax error'], + 'empty options' => ['[]', 'Unable to parse empty option argument'], + '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'], + + ]; + } + + /** + * @dataProvider unvalidOptionsProvider + */ + public function testRunWithInvalidOptionsFormatShouldReturnAnError(string $options, string $expectedOutput) { $this->expectOutputString( - "\033[31m/!\ Error: Unable to parse option argument: Syntax error\033[0m" . PHP_EOL . + "\033[31m/!\ Error: $expectedOutput\033[0m" . PHP_EOL . PHP_EOL ); + $this->assertEquals(1, $this->cli->run([ 'php-css-lint', - '--options={ "allowedIndentationChars": }', + '--options=' . $options, '.test { display: block; }', ])); } + + public function validCssFilesProvider(): array + { + return [ + 'bootstrap.css' => ['bootstrap.css'], + 'normalize.css' => ['normalize.css'], + 'tailwind.css' => ['tailwind.css'], + ]; + } + + /** + * @dataProvider validCssFilesProvider + */ + 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 . + PHP_EOL + ); + $this->assertEquals(0, $this->cli->run(['php-css-lint', $fileToLint]), $this->getActualOutput()); + } } diff --git a/tests/TestSuite/LintConfigurationTest.php b/tests/TestSuite/LintConfigurationTest.php index 5db1a04..620267d 100644 --- a/tests/TestSuite/LintConfigurationTest.php +++ b/tests/TestSuite/LintConfigurationTest.php @@ -75,7 +75,7 @@ public function testMergeConstructorsShouldAddAContructor() $this->assertTrue($lintConfiguration->propertyExists('-new-animation-trigger')); } - public function testMergePropertiesStandardsShouldDisableAContructor() + public function testMergePropertiesStandardsShouldDisableAProperty() { $lintConfiguration = new LintConfiguration(); $this->assertTrue($lintConfiguration->propertyExists('align-content')); @@ -84,7 +84,7 @@ public function testMergePropertiesStandardsShouldDisableAContructor() $this->assertFalse($lintConfiguration->propertyExists('align-content')); } - public function testMergePropertiesStandardsShouldAddAContructor() + public function testMergePropertiesStandardsShouldAddAProperty() { $lintConfiguration = new LintConfiguration(); $this->assertFalse($lintConfiguration->propertyExists('new-content')); @@ -93,7 +93,7 @@ public function testMergePropertiesStandardsShouldAddAContructor() $this->assertTrue($lintConfiguration->propertyExists('new-content')); } - public function testMergePropertiesNonStandardsShouldDisableAContructor() + public function testMergePropertiesNonStandardsShouldDisableAProperty() { $lintConfiguration = new LintConfiguration(); $this->assertTrue($lintConfiguration->propertyExists('-moz-animation-trigger')); @@ -102,7 +102,7 @@ public function testMergePropertiesNonStandardsShouldDisableAContructor() $this->assertFalse($lintConfiguration->propertyExists('-moz-animation-trigger')); } - public function testMergePropertiesNonStandardsShouldAddAContructor() + public function testMergePropertiesNonStandardsShouldAddAProperty() { $lintConfiguration = new LintConfiguration(); $this->assertFalse($lintConfiguration->propertyExists('-moz-new-content')); @@ -111,6 +111,78 @@ public function testMergePropertiesNonStandardsShouldAddAContructor() $this->assertTrue($lintConfiguration->propertyExists('-moz-new-content')); } + public function testMergeAtRulesStandardsShouldDisableAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertTrue($lintConfiguration->atRuleExists('charset')); + + $lintConfiguration->mergeAtRulesStandards(['charset' => false]); + $this->assertFalse($lintConfiguration->atRuleExists('charset')); + } + + public function testMergeAtRulesStandardsShouldAddAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertFalse($lintConfiguration->atRuleExists('new-at-rule')); + + $lintConfiguration->mergeAtRulesStandards(['new-at-rule' => true]); + $this->assertTrue($lintConfiguration->atRuleExists('new-at-rule')); + } + + public function testMergeAtRulesNonStandardsShouldDisableAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertTrue($lintConfiguration->atRuleExists('document')); + + $lintConfiguration->mergeAtRulesNonStandards(['document' => false]); + $this->assertFalse($lintConfiguration->atRuleExists('document')); + } + + public function testMergeAtRulesNonStandardsShouldAddAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertFalse($lintConfiguration->atRuleExists('new-at-rule')); + + $lintConfiguration->mergeAtRulesNonStandards(['new-at-rule' => true]); + $this->assertTrue($lintConfiguration->atRuleExists('new-at-rule')); + } + + public function testMergeAtRulesPropertiesStandardsShouldDisableAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertTrue($lintConfiguration->atRulePropertyExists('font-face', 'font-display')); + + $lintConfiguration->mergeAtRulesPropertiesStandards(['font-face' => ['font-display' => false]]); + $this->assertFalse($lintConfiguration->atRulePropertyExists('font-face', 'font-display')); + } + + public function testMergeAtRulesPropertiesStandardsShouldAddAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertFalse($lintConfiguration->atRulePropertyExists('font-face', 'new-at-rule')); + + $lintConfiguration->mergeAtRulesPropertiesStandards(['font-face' => ['new-at-rule' => true]]); + $this->assertTrue($lintConfiguration->atRulePropertyExists('font-face', 'new-at-rule')); + } + + public function testMergeAtRulesPropertiesNonStandardsShouldDisableAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertTrue($lintConfiguration->atRulePropertyExists('font-face', 'font-variant')); + + $lintConfiguration->mergeAtRulesPropertiesNonStandards(['font-face' => ['font-variant' => false]]); + $this->assertFalse($lintConfiguration->atRulePropertyExists('font-face', 'font-variant')); + } + + public function testMergeAtRulesPropertiesNonStandardsShouldAddAProperty() + { + $lintConfiguration = new LintConfiguration(); + $this->assertFalse($lintConfiguration->atRulePropertyExists('font-face', 'new-at-rule')); + + $lintConfiguration->mergeAtRulesPropertiesNonStandards(['font-face' => ['new-at-rule' => true]]); + $this->assertTrue($lintConfiguration->atRulePropertyExists('font-face', 'new-at-rule')); + } + public function testSetOptionsAllowedIndentationChars() { $lintConfiguration = new LintConfiguration(); diff --git a/tests/TestSuite/LinterTest.php b/tests/TestSuite/LinterTest.php index 3edb8a5..e72dc95 100644 --- a/tests/TestSuite/LinterTest.php +++ b/tests/TestSuite/LinterTest.php @@ -7,7 +7,7 @@ use org\bovigo\vfs\vfsStreamFile; use CssLint\Linter; use CssLint\LintConfiguration; -use PHPUnit\Framework\TestCase; +use CssLint\Tokenizer\Tokenizer; use InvalidArgumentException; use TypeError; @@ -41,9 +41,24 @@ public function testConstructWithCustomCssLintProperties() $this->assertSame($lintConfiguration, $linter->getLintConfiguration()); } + public function testConstructWithCustomTokenizer() + { + $tokenizer = new Tokenizer(); + $linter = new Linter(null, $tokenizer); + $this->assertSame($tokenizer, $linter->getTokenizer()); + } + + public function testSetTokenizer() + { + $tokenizer = new Tokenizer(); + $this->linter->setTokenizer($tokenizer); + $this->assertSame($tokenizer, $this->linter->getTokenizer()); + } + public function testLintValidString() { - $this->assertTrue($this->linter->lintString('.button.dropdown::after { + $errors = iterator_to_array( + $this->linter->lintString('.button.dropdown::after { display: block; width: 0; height: 0; @@ -60,78 +75,82 @@ public function testLintValidString() .button.arrow-only::after { top: -0.1em; float: none; - margin-left: 0; }'), print_r($this->linter->getErrors(), true)); + margin-left: 0; }'), + false + ); + $this->assertEmpty($errors, json_encode($errors, JSON_PRETTY_PRINT)); } public function testLintNotValidString() { - $this->assertFalse($this->linter->lintString('.button.dropdown::after { + // Act + $errors = $this->linter->lintString('.button.dropdown::after { displady: block; width: 0; : - ')); - $this->assertSame([ - 'Unknown CSS property "displady" (line: 2, char: 22)', - 'Unexpected char ":" (line: 4, char: 5)', - ], $this->linter->getErrors()); - } - - public function testLintValidStringContainingTabs() - { - $this->linter->getLintConfiguration()->setAllowedIndentationChars(["\t"]); - $this->assertTrue( - $this->linter->lintString( - "\t\t" . '.button.dropdown::after { -' . "\t\t" . 'display: block; -' . "\t\t" . '}' - ), - print_r($this->linter->getErrors(), true) - ); - - $this->linter->getLintConfiguration()->setAllowedIndentationChars([' ']); + '); + + // Assert + $this->assertErrorsEquals([ + [ + 'key' => 'invalid_property_declaration', + 'message' => 'property - Unknown property "displady"', + 'start' => [ + 'line' => 2, + 'column' => 14, + ], + 'end' => [ + 'line' => 2, + 'column' => 29, + ], + ], + [ + 'key' => 'unclosed_token', + 'message' => 'block - Unclosed "block" detected', + 'start' => [ + 'line' => 1, + 'column' => 24, + ], + 'end' => [ + 'line' => 5, + 'column' => 14, + ], + ], + [ + 'key' => 'unexpected_character_end_of_content', + 'message' => 'Unexpected character at end of content: ":"', + 'start' => [ + 'line' => 5, + 'column' => 1, + ], + 'end' => [ + 'line' => 5, + 'column' => 14, + ], + ], + ], $errors); } public function testLintStringWithUnterminatedContext() { - $this->assertFalse($this->linter->lintString('* {')); - $this->assertSame([ - 'Unterminated "selector content" - "{" (line: 1, char: 3)', - ], $this->linter->getErrors()); - } - - public function testLintStringWithWrongSelectorDoubleComma() - { - $this->assertFalse($this->linter->lintString('a,, {}')); - $this->assertSame([ - 'Selector token "," cannot be preceded by "a," (line: 1, char: 3)', - ], $this->linter->getErrors()); - } - - public function testLintStringWithWrongSelectorDoubleHash() - { - $this->assertFalse($this->linter->lintString('## {}')); - $this->assertSame([ - 'Selector token "#" cannot be preceded by "#" (line: 1, char: 2)', - ], $this->linter->getErrors()); - } - - public function testLintStringWithWrongPropertyNameUnexpectedToken() - { - $this->assertFalse($this->linter->lintString('.test { - test~: true; -}')); - $this->assertSame([ - 'Unexpected property name token "~" (line: 2, char: 10)', - 'Unknown CSS property "test~" (line: 2, char: 11)', - ], $this->linter->getErrors()); - } - - public function testLintStringWithWrongSelectorUnexpectedToken() - { - $this->assertFalse($this->linter->lintString('.a| {}')); - $this->assertSame([ - 'Unexpected selector token "|" (line: 1, char: 3)', - ], $this->linter->getErrors()); + // Act + $errors = $this->linter->lintString('* {'); + + // Assert + $this->assertErrorsEquals([ + [ + 'key' => 'unclosed_token', + 'message' => 'block - Unclosed "block" detected', + 'start' => [ + 'line' => 1, + 'column' => 1, + ], + 'end' => [ + 'line' => 1, + 'column' => 4, + ], + ], + ], $errors); } public function testLintStringWithWrongTypeParam() @@ -140,29 +159,29 @@ public function testLintStringWithWrongTypeParam() $this->expectExceptionMessage( 'CssLint\Linter::lintString(): Argument #1 ($stringValue) must be of type string, array given' ); - $this->linter->lintString(['wrong']); + iterator_to_array($this->linter->lintString(['wrong']), false); } public function testLintFileWithWrongTypeParam() { $this->expectException(TypeError::class); $this->expectExceptionMessage( - 'CssLint\Linter::lintFile(): Argument #1 ($sFilePath) must be of type string, array given' + 'CssLint\Linter::lintFile(): Argument #1 ($filePath) must be of type string, array given' ); - $this->linter->lintFile(['wrong']); + iterator_to_array($this->linter->lintFile(['wrong']), false); } public function testLintFileWithUnknownFilePathParam() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Argument "$sFilePath" "wrong" is not an existing file path'); - $this->linter->lintFile('wrong'); + $this->expectExceptionMessage('Argument "$filePath" "wrong" is not an existing file path'); + iterator_to_array($this->linter->lintFile('wrong'), false); } public function testLintFileWithUnreadableFilePathParam() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Argument "$sFilePath" "vfs://testDir/foo.txt" is not a readable file path'); + $this->expectExceptionMessage('Argument "$filePath" "vfs://testDir/foo.txt" is not a readable file path'); $testFile = new vfsStreamFile('foo.txt', 0o000); $this->root->addChild($testFile); @@ -171,101 +190,61 @@ public function testLintFileWithUnreadableFilePathParam() $this->assertFileIsNotReadable($fileToLint); - $this->linter->lintFile($fileToLint); - } - - public function testLintValidImportRule() - { - $this->assertTrue( - $this->linter->lintString("@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');"), - print_r($this->linter->getErrors(), true) - ); - - $this->assertTrue( - $this->linter->lintString("@import url('https://fonts.googleapis.com/css2?family=Comic+Neue:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap');"), - print_r($this->linter->getErrors(), true) - ); - } - - public function testLintNotValidImportRule() - { - $this->assertFalse( - $this->linter->lintString("@import url('"), - ); - $this->assertSame([ - 'Unterminated "selector" - "@import url(\'" (line: 1, char: 13)', - ], $this->linter->getErrors()); + iterator_to_array($this->linter->lintFile($fileToLint), false); } public function testLintComment() { - $this->assertTrue( + // Act + $errors = $this->linter->lintString( "/*" . PHP_EOL . " * This is a comment" . PHP_EOL . "*/" . PHP_EOL . ".test { }" - ), - print_r($this->linter->getErrors(), true) - ); - } + ); - public function testLintAtRule() - { - $this->assertTrue( - $this->linter->lintString( - '@charset "UTF-8";' . PHP_EOL . - ".test { }" - ), - print_r($this->linter->getErrors(), true) - ); - } - - public function testLintSpecificCss() - { - $this->assertTrue( - $this->linter->lintString( - '.row-gap-xxl-0{row-gap:0!important}' - ), - print_r($this->linter->getErrors(), true) - ); - } - - public function testLintBootstrapCssFile() - { - $fileToLint = $this->testFixturesDir . '/bootstrap.css'; - $this->assertTrue( - $this->linter->lintFile($fileToLint), - print_r($this->linter->getErrors(), true) - ); - } - - public function testLintNormalizeCssFile() - { - $fileToLint = $this->testFixturesDir . '/normalize.css'; - $this->assertTrue( - $this->linter->lintFile($fileToLint), - print_r($this->linter->getErrors(), true) - ); - } - - public function testLintTailwindCssFile() - { - $fileToLint = $this->testFixturesDir . '/tailwind.css'; - $this->assertTrue( - $this->linter->lintFile($fileToLint), - print_r($this->linter->getErrors(), true) - ); + // Assert + $this->assertErrorsEquals([], $errors, json_encode($errors, JSON_PRETTY_PRINT)); } public function testLintNotValidCssFile() { + // Arrange $fileToLint = $this->testFixturesDir . '/not_valid.css'; - $this->assertFalse($this->linter->lintFile($fileToLint)); - $this->assertSame([ - 'Unknown CSS property "bordr-top-style" (line: 8, char: 20)', - 'Unterminated "selector content" (line: 17, char: 0)', - ], $this->linter->getErrors()); + // Act + $errors = $this->linter->lintFile($fileToLint); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => 'invalid_property_declaration', + 'message' => 'property - Unknown property "bordr-top-style"', + 'start' => [ + 'line' => 3, + 'column' => 5, + ], + 'end' => [ + 'line' => 3, + 'column' => 27, + ], + ], + [ + 'key' => 'unclosed_token', + 'message' => 'block - Unclosed "block" detected', + 'start' => [ + 'line' => 1, + 'column' => 23, + ], + 'end' => [ + 'line' => 6, + 'column' => 2, + ], + ], + ], + $errors + ); } } diff --git a/tests/TestSuite/TestCase.php b/tests/TestSuite/TestCase.php new file mode 100644 index 0000000..dcbb0de --- /dev/null +++ b/tests/TestSuite/TestCase.php @@ -0,0 +1,38 @@ +> $expected + * @param array $actual + */ + protected function assertErrorsEquals(array $expected, Generator $actual) + { + + $actual = iterator_to_array($actual, false); + $this->assertCount(count($expected), $actual, json_encode($actual, JSON_PRETTY_PRINT)); + foreach ($actual as $key => $error) { + $this->assertInstanceOf(LintError::class, $error, "Error at index $key is not a LintError"); + $this->assertEquals( + $expected[$key], + $error->jsonSerialize(), + "Error at index $key does not match expected value." + ); + } + } +} diff --git a/tests/TestSuite/TokenLinter/AtRuleTokenLinterTest.php b/tests/TestSuite/TokenLinter/AtRuleTokenLinterTest.php new file mode 100644 index 0000000..bb7b371 --- /dev/null +++ b/tests/TestSuite/TokenLinter/AtRuleTokenLinterTest.php @@ -0,0 +1,136 @@ +configuration = new LintConfiguration(); + $this->linter = new AtRuleTokenLinter($this->configuration); + } + + public function testSupportsOnlyAtRuleTokens(): void + { + $token = new AtRuleToken('media', 'screen', new Position(1, 0), new Position(1, 10)); + $this->assertTrue($this->linter->supports($token), 'Should support AtRuleToken'); + + $nonAtRuleToken = new BlockToken([], new Position(1, 0), new Position(1, 4)); + $this->assertFalse($this->linter->supports($nonAtRuleToken), 'Should not support non-AtRuleToken'); + } + + public function validAtRulesProvider(): array + { + return [ + 'simple import' => ['import', '"styles.css"'], + 'import with url()' => ['import', 'url("styles.css")'], + 'import with media query' => ['import', '"print.css" print'], + 'import with supports' => ['import', '"grid.css" supports(display: grid)'], + 'import with layer' => ['import', '"base.css" layer(base)'], + 'import with media and layer' => ['import', '"theme.css" layer(theme) screen'], + 'import rule' => ['import', 'url(\'https://fonts.googleapis.com/css2?family=Poppins&display=swap\')'], + 'complex import rule' => ['import', 'url(\'https://fonts.googleapis.com/css2?family=Comic+Neue:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap\')'], + 'charset declaration' => ['charset', '"UTF-8"'], + 'media query' => ['media', 'screen'], + 'supports query' => ['supports', '(display: grid)'], + 'layer declaration' => ['layer', 'base'], + 'keyframes' => ['keyframes', 'slide-in'], + 'font-face' => ['font-face', null], + 'page' => ['page', ':first'], + 'namespace' => ['namespace', 'svg url("http://www.w3.org/2000/svg")'], + ]; + } + + /** + * @dataProvider validAtRulesProvider + */ + public function testNoErrorsForValidAtRules(string $name, ?string $value): void + { + // Arrange + $token = new AtRuleToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + (strlen($value ?? '') + 1))); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function invalidAtRulesProvider(): array + { + return [ + 'empty name' => ['', null, 'At-rule name is empty'], + 'unknown at-rule' => ['unknown', null, 'Unknown at-rule "unknown"'], + 'empty import value' => ['import', null, 'Import value is empty'], + 'import with unquoted URL' => ['import', 'styles.css', 'Import URL must be a quoted string or url() function'], + 'import with invalid URL format' => ['import', 'url(styles.css', 'Import URL must be a quoted string or url() function'], + 'import with invalid media query' => ['import', '"styles.css" invalid-media', 'Invalid import conditions. Must be a valid media query, supports() condition, or layer() declaration'], + 'import with invalid supports' => ['import', '"styles.css" supports()', 'Invalid import conditions. Must be a valid media query, supports() condition, or layer() declaration'], + 'import with invalid layer' => ['import', '"styles.css" layer', 'Invalid import conditions. Must be a valid media query, supports() condition, or layer() declaration'], + 'charset without quotes' => ['charset', 'UTF-8', 'Charset value must be a quoted string'], + 'empty charset value' => ['charset', null, 'Charset value must be a quoted string'], + 'layer with invalid characters' => ['layer', '#invalid', 'Layer value is not valid: "#invalid"'], + 'layer with ending comma' => ['layer', 'invalid layer,', 'Layer value should not have a comma at the end'], + 'layer with consecutive commas' => ['layer', 'invalid,,layer', 'Layer value should not have consecutive commas'], + ]; + } + + /** + * @dataProvider invalidAtRulesProvider + */ + public function testErrorsForInvalidAtRules(string $name, ?string $value, string $expectedMessage): void + { + // Arrange + $token = new AtRuleToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + (strlen($value ?? '') + 1))); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::INVALID_AT_RULE_DECLARATION->value, + 'message' => sprintf('at-rule - %s', $expectedMessage), + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => strlen($name) + (strlen($value ?? '') + 1), + ], + ], + ], + $errors + ); + } + + public function testThrowsExceptionForNonAtRuleToken(): void + { + // Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('AtRuleTokenLinter can only lint AtRuleToken'); + + // Arrange + $token = new BlockToken([], new Position(1, 0), new Position(1, 4)); + + // Act + iterator_to_array($this->linter->lint($token), false); + } +} diff --git a/tests/TestSuite/TokenLinter/IndentationTokenLinterTest.php b/tests/TestSuite/TokenLinter/IndentationTokenLinterTest.php new file mode 100644 index 0000000..0a4a68d --- /dev/null +++ b/tests/TestSuite/TokenLinter/IndentationTokenLinterTest.php @@ -0,0 +1,158 @@ +configuration = new LintConfiguration(); + $this->linter = new IndentationTokenLinter($this->configuration); + } + + public function testSupportsOnlyWhitespaceTokens(): void + { + $token = new WhitespaceToken(' ', new Position(1, 0), new Position(1, 4)); + $this->assertTrue($this->linter->supports($token), 'Should support WhitespaceToken'); + + $nonWhitespaceToken = new BlockToken([], new Position(1, 0), new Position(1, 4)); + $this->assertFalse($this->linter->supports($nonWhitespaceToken), 'Should not support non-WhitespaceToken'); + } + + public function testThrowsExceptionForNonPropertyToken(): void + { + $nonPropertyToken = new BlockToken([], new Position(1, 0), new Position(1, 4)); + $this->expectException(InvalidArgumentException::class); + iterator_to_array($this->linter->lint($nonPropertyToken)); + } + + public function testLintWithValidIndentation(): void + { + // Arrange + $token = new WhitespaceToken(' ', new Position(1, 0), new Position(1, 4)); + $this->configuration->setAllowedIndentationChars([' ']); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function testLintWithInvalidTabIndentation(): void + { + // Arrange + $token = new WhitespaceToken("\n\t", new Position(1, 0), new Position(1, 1)); + $this->configuration->setAllowedIndentationChars([' ']); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([ + [ + 'key' => 'invalid_indentation_character', + 'message' => 'whitespace - Unexpected char "\t"', + 'start' => [ + 'line' => 2, + 'column' => 1, + ], + 'end' => [ + 'line' => 2, + 'column' => 2, + ], + ], + ], $errors); + } + + public function testLintWithInvalidSpaceIndentation(): void + { + // Arrange + $token = new WhitespaceToken("\n ", new Position(1, 0), new Position(1, 1)); + $this->configuration->setAllowedIndentationChars(['\t']); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([ + [ + 'key' => 'invalid_indentation_character', + 'message' => 'whitespace - Unexpected char " "', + 'start' => [ + 'line' => 2, + 'column' => 1, + ], + 'end' => [ + 'line' => 2, + 'column' => 2, + ], + ], + ], $errors); + } + + public function testLintWithMultipleAllowedCharacters(): void + { + // Arrange + $token = new WhitespaceToken(" \t ", new Position(1, 0), new Position(1, 5)); + $this->configuration->setAllowedIndentationChars([' ', "\t"]); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function testLintWithMultilineIndentation(): void + { + // Arrange + $token = new WhitespaceToken("\n \n\t \t", new Position(1, 0), new Position(1, 7)); + $this->configuration->setAllowedIndentationChars([' ']); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([ + [ + 'key' => 'invalid_indentation_character', + 'message' => 'whitespace - Unexpected char "\t"', + 'start' => [ + 'line' => 3, + 'column' => 1, + ], + 'end' => [ + 'line' => 3, + 'column' => 4, + ], + ], + ], $errors); + } + + public function testLintWithEmptyToken(): void + { + // Arrange + $token = new WhitespaceToken('', new Position(1, 0), new Position(1, 0)); + $this->configuration->setAllowedIndentationChars([' ']); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } +} diff --git a/tests/TestSuite/TokenLinter/PropertyTokenLinterTest.php b/tests/TestSuite/TokenLinter/PropertyTokenLinterTest.php new file mode 100644 index 0000000..c2ab973 --- /dev/null +++ b/tests/TestSuite/TokenLinter/PropertyTokenLinterTest.php @@ -0,0 +1,312 @@ +configuration = new LintConfiguration(); + $this->linter = new PropertyTokenLinter($this->configuration); + } + + public function testSupportsOnlyPropertyTokens(): void + { + $token = new PropertyToken('color', 'red', new Position(1, 0), new Position(1, 10)); + $this->assertTrue($this->linter->supports($token), 'Should support PropertyToken'); + + $nonPropertyToken = new BlockToken([], new Position(1, 0), new Position(1, 4)); + $this->assertFalse($this->linter->supports($nonPropertyToken), 'Should not support non-PropertyToken'); + } + + public function testThrowsExceptionForNonPropertyToken(): void + { + $nonPropertyToken = new BlockToken([], new Position(1, 0), new Position(1, 4)); + $this->expectException(InvalidArgumentException::class); + iterator_to_array($this->linter->lint($nonPropertyToken)); + } + + public function validPropertiesProvider(): array + { + return [ + 'standard property' => ['color', 'red'], + 'property with value' => ['width', '100px'], + 'property with multiple values' => ['margin', '10px 20px'], + 'property with important' => ['color', 'blue !important'], + 'font family' => ['font-family', 'Arial, sans-serif'], + ]; + } + + /** + * @dataProvider validPropertiesProvider + */ + public function testNoErrorsForValidProperties(string $name, string $value): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $token = new PropertyToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + strlen($value) + 1)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function invalidPropertiesProvider(): array + { + return [ + 'empty property name' => ['', 'value', 'Property name is empty'], + 'unknown property' => ['unknown-prop', 'value', 'Unknown property "unknown-prop"'], + 'property name with invalid characters' => ['#invalid-prop', 'value', 'Invalid property name format: "#invalid-prop"'], + ]; + } + + /** + * @dataProvider invalidPropertiesProvider + */ + public function testErrorsForInvalidProperties(string $name, string $value, string $expectedMessage): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $token = new PropertyToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + strlen($value) + 1)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::INVALID_PROPERTY_DECLARATION->value, + 'message' => sprintf('property - %s', $expectedMessage), + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => strlen($name) + strlen($value) + 1, + ], + ], + ], + $errors + ); + } + + public function validVariablesProvider(): array + { + return [ + 'simple variable' => ['--primary-color', 'blue'], + 'variable with numbers' => ['--size-1', '10px'], + 'variable with multiple dashes' => ['--my-custom-var', 'value'], + ]; + } + + /** + * @dataProvider validVariablesProvider + */ + public function testNoErrorsForValidVariables(string $name, string $value): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $token = new PropertyToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + strlen($value) + 1)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function invalidVariablesProvider(): array + { + return [ + 'single dash' => ['-invalid', 'value', 'Unknown property "-invalid"'], + 'no dash prefix' => ['invalid', 'value', 'Unknown property "invalid"'], + 'special characters' => ['--invalid@var', 'value', 'Invalid variable format: "--invalid@var"'], + 'space in name' => ['--invalid var', 'value', 'Invalid variable format: "--invalid var"'], + ]; + } + + /** + * @dataProvider invalidVariablesProvider + */ + public function testErrorsForInvalidVariables(string $name, string $value, string $expectedMessage): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $token = new PropertyToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + strlen($value) + 1)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::INVALID_PROPERTY_DECLARATION->value, + 'message' => 'property - ' . $expectedMessage, + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => strlen($name) + strlen($value) + 1, + ], + ], + ], + $errors + ); + } + + public function validAtRulesPropertiesProvider(): array + { + return [ + 'standard at-rule property' => ['font-face', 'font-display', 'swap'], + 'non standard at-rule property' => ['font-face', 'font-variant', 'normal'], + ]; + } + + /** + * @dataProvider validAtRulesPropertiesProvider + */ + public function testNoErrorsForValidAtRulesProperties(string $atRuleName, string $name, string $value): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $atRuleToken = new AtRuleToken($atRuleName, null, new Position(1, 0), new Position(1, 10)); + $blockToken->setPreviousToken($atRuleToken); + + $token = new PropertyToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + strlen($value) + 1)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function invalidAtRulesPropertiesProvider(): array + { + return [ + 'unknown at-rule property' => ['font-face', 'unknown-prop', 'value', 'Property "unknown-prop" is not valid in @font-face rule'], + ]; + } + + /** + * @dataProvider invalidAtRulesPropertiesProvider + */ + public function testErrorsForInvalidAtRulesProperties(string $atRuleName, string $name, string $value, string $expectedMessage): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $atRuleToken = new AtRuleToken($atRuleName, null, new Position(1, 0), new Position(1, 10)); + $blockToken->setPreviousToken($atRuleToken); + + $token = new PropertyToken($name, $value, new Position(1, 0), new Position(1, strlen($name) + strlen($value) + 1)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::INVALID_PROPERTY_DECLARATION->value, + 'message' => 'property - ' . $expectedMessage, + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => strlen($name) + strlen($value) + 1, + ], + ], + ], + $errors, + ); + } + + public function testErrorsForEmptyPropertyValue(): void + { + // Arrange + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 10)); + $token = new PropertyToken('color', null, new Position(1, 0), new Position(1, 5)); + $blockToken->addToken($token); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::INVALID_PROPERTY_DECLARATION->value, + 'message' => 'property - Property value is empty', + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => 5, + ], + ], + ], + $errors, + ); + } + + public function testErrorsForPropertyNotInBlockToken(): void + { + // Arrange + $token = new PropertyToken('color', 'red', new Position(1, 0), new Position(1, 10)); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::INVALID_PROPERTY_DECLARATION->value, + 'message' => 'property - Property must be inside a block', + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => 10, + ], + ], + ], + $errors, + ); + } +} diff --git a/tests/TestSuite/TokenLinter/SelectorTokenLinterTest.php b/tests/TestSuite/TokenLinter/SelectorTokenLinterTest.php new file mode 100644 index 0000000..092e9f5 --- /dev/null +++ b/tests/TestSuite/TokenLinter/SelectorTokenLinterTest.php @@ -0,0 +1,120 @@ +linter = new SelectorTokenLinter(); + } + + public function validSelectorsProvider(): array + { + return [ + 'simple class selector' => ['.class-name'], + 'id selector' => ['#my-id'], + 'element selector' => ['div'], + 'multiple classes' => ['.class1.class2'], + 'element with class' => ['div.class'], + 'element with id' => ['div#id'], + 'pseudo class' => ['a:hover'], + 'complex selector' => ['div.class-name:hover'], + 'kebab case class' => ['.my-class-name'], + 'underscore in class' => ['.my_class_name'], + 'numbers in selector' => ['.class123'], + 'button with pseudo class' => ['.button.dropdown::after'], + 'complex selector with multiple classes' => [':where(select:is([multiple], [size])) optgroup'], + ]; + } + + /** + * @dataProvider validSelectorsProvider + */ + public function testNoErrorsForValidSelectors(string $selector): void + { + // Arrange + $token = new SelectorToken($selector, new Position(1, 0), new Position(1, strlen($selector))); + + // Act + $errors = $this->linter->lint($token); + + // Assert + $this->assertErrorsEquals([], $errors); + } + + public function invalidSelectorsProvider(): array + { + return [ + 'contains special character @' => ['.class@name', 'Selector contains invalid characters'], + 'contains special character !' => ['.class!name', 'Selector contains invalid characters'], + 'contains special character $' => ['.class$name', 'Selector contains invalid characters'], + 'contains special character %' => ['.class%name', 'Selector contains invalid characters'], + 'contains special character ^' => ['.class^name', 'Selector contains invalid characters'], + 'contains special character &' => ['.class&name', 'Selector contains invalid characters'], + 'contains special character *' => ['.class*name', 'Selector contains invalid characters'], + 'contains braces' => ['.class{name}', 'Selector contains invalid characters'], + 'contains pipe' => ['.a|', 'Selector contains invalid characters'], + 'double comma' => ['a,,', 'Selector contains invalid consecutive characters'], + 'double hash' => ['##test', 'Selector contains invalid consecutive characters'], + 'unbalanced parentheses' => ['.class(name))', 'Selector contains invalid number of parentheses'], + ]; + } + + /** + * @dataProvider invalidSelectorsProvider + */ + public function testErrorsForInvalidSelectors(string $selector, $message): void + { + // Arrance + $token = new SelectorToken($selector, new Position(1, 0), new Position(1, strlen($selector))); + + // Act + $errors = $this->linter->lint($token); + + $this->assertErrorsEquals( + [ + [ + 'key' => LintErrorKey::UNEXPECTED_SELECTOR_CHARACTER->value, + 'message' => sprintf('selector - %s: "%s"', $message, $selector), + 'start' => [ + 'line' => 1, + 'column' => 0, + ], + 'end' => [ + 'line' => 1, + 'column' => strlen($selector), + ], + ], + ], + $errors, + ); + } + + public function testDoesNotSupportNonSelectorTokens(): void + { + // Assert + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('SelectorTokenLinter can only lint SelectorToken'); + + // Arrange + $token = new PropertyToken('color', 'red', new Position(1, 0), new Position(1, 5)); + + // Act + iterator_to_array($this->linter->lint($token), false); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/AtRuleParserTest.php b/tests/TestSuite/Tokenizer/Parser/AtRuleParserTest.php new file mode 100644 index 0000000..13c0222 --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/AtRuleParserTest.php @@ -0,0 +1,195 @@ +parser = new AtRuleParser(); + } + + public function validAtRuleProvider(): array + { + return [ + 'simple media query' => ['@media screen;', 'media', 'screen'], + 'charset rule' => ['@charset "UTF-8";', 'charset', '"UTF-8"'], + 'import rule' => ['@import url("styles.css");', 'import', 'url("styles.css")'], + 'keyframes rule' => ['@keyframes slide-in {', 'keyframes', 'slide-in'], + 'font-face rule' => ['@font-face {', 'font-face', null], + 'supports rule' => ['@supports (display: grid) {', 'supports', '(display: grid)'], + 'page rule' => ['@page :first {', 'page', ':first'], + 'namespace rule' => ['@namespace svg url("http://www.w3.org/2000/svg");', 'namespace', 'svg url("http://www.w3.org/2000/svg")'], + ]; + } + + /** + * @dataProvider validAtRuleProvider + */ + public function testParsesValidAtRule(string $content, string $expectedName, ?string $expectedValue): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(AtRuleToken::class, $token); + $this->assertEquals($expectedName, $token->getName()); + $this->assertEquals($expectedValue, $token->getValue()); + } + + public function testIgnoresNonAtRuleContent(): void + { + // Arrange + $content = 'not-an-at-rule'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } + + public function testHandlesAtRuleInBlock(): void + { + // Arrange + $content = '@media screen;'; + $tokenizerContext = new TokenizerContext(); + $blockToken = new BlockToken([], new Position(1, 0)); + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->setCurrentBlockToken($blockToken); + + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + + $blockTokenTokens = $blockToken->getValue(); + $this->assertCount(1, $blockTokenTokens, json_encode($blockTokenTokens, JSON_PRETTY_PRINT)); + $this->assertSame($tokens[0], $blockTokenTokens[0]); + $token = $blockTokenTokens[0]; + + $this->assertInstanceOf(AtRuleToken::class, $token); + + /** @var AtRuleToken $token */ + $this->assertTrue($token->isComplete()); + $this->assertEquals('media', $token->getName()); + $this->assertEquals('screen', $token->getValue()); + } + + public function testHandlesAtRuleBlockInBlock(): void + { + // Arrange + $content = '@media screen {'; + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 0)); + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->setCurrentBlockToken($blockToken); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + + $blockTokenTokens = $blockToken->getValue(); + $this->assertCount(1, $blockTokenTokens); + $this->assertSame($tokens[0], $blockTokenTokens[0]); + $token = $blockTokenTokens[0]; + + $this->assertInstanceOf(AtRuleToken::class, $token); + /** @var AtRuleToken $token */ + $this->assertEquals('media', $token->getName()); + $this->assertEquals('screen', $token->getValue()); + } + + public function testHandlesAtRuleWithBlockAndProperties(): void + { + // Arrange + $content = '@font-face {'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(AtRuleToken::class, $token); + $this->assertEquals('font-face', $token->getName()); + $this->assertEquals('', $token->getValue()); + } + + public function testIgnoresWhitespace(): void + { + // Arrange + $content = ' '; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/BlockParserTest.php b/tests/TestSuite/Tokenizer/Parser/BlockParserTest.php new file mode 100644 index 0000000..1c26e6c --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/BlockParserTest.php @@ -0,0 +1,184 @@ +parser = new BlockParser(); + } + + public function testGetHandledTokenClass(): void + { + $this->assertEquals(BlockToken::class, $this->parser->getHandledTokenClass()); + } + + public function blockStartProvider(): array + { + return [ + 'simple block start' => ['{', true], + 'block start with content' => ['test {', true], + 'block start with quotes' => ['"test" {', true], + 'block start with escaped quotes' => ['"test\" {', true], + 'block start with single quotes' => ["'test' {", true], + 'block start with escaped single quotes' => ["'test\\' {", true], + 'invalid block start' => ['test', false], + 'invalid block start with quotes' => ['"test {', false], + 'invalid block start with single quotes' => ["'test {", false], + ]; + } + + /** + * @dataProvider blockStartProvider + */ + public function testIsBlockStart(string $content, bool $expected): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent($content); + + // Act + $result = BlockParser::isBlockStart($tokenizerContext); + + // Assert + $this->assertEquals($expected, $result); + } + + public function blockEndProvider(): array + { + return [ + 'simple block end' => ['}', true], + 'block end with content' => ['test }', true], + 'block end with spaces' => [' }', true], + 'invalid block end' => ['test', false], + 'invalid block end with similar char' => ['test ]', false], + ]; + } + + /** + * @dataProvider blockEndProvider + */ + public function testIsBlockEnd(string $content, bool $expected): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent($content); + + // Act + $result = BlockParser::isBlockEnd($tokenizerContext); + + // Assert + $this->assertEquals($expected, $result); + } + + public function blockContentProvider(): array + { + return [ + 'empty block' => ['{}', ''], + 'block with content' => ['{test}', 'test'], + 'block with spaces' => ['{ test }', 'test'], + 'block with multiple lines' => ["{\n test\n}", "test"], + ]; + } + + /** + * @dataProvider blockContentProvider + */ + public function testGetBlockContent(string $content, string $expected): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent($content); + + // Act + $result = BlockParser::getBlockContent($tokenizerContext); + + // Assert + $this->assertEquals($expected, $result); + } + + public function testParseCurrentContextWithBlockStart(): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent('{'); + + // Act + $result = $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $this->assertNull($result); + $this->assertInstanceOf(BlockToken::class, $tokenizerContext->getCurrentBlockToken()); + } + + public function testParseCurrentContextWithBlockEnd(): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent('{'); + $this->parser->parseCurrentContext($tokenizerContext); + $tokenizerContext->appendCurrentContent('}'); + + // Act + $result = $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $this->assertInstanceOf(BlockToken::class, $result); + $this->assertNull($tokenizerContext->getCurrentBlockToken()); + } + + public function testParseCurrentContextWithNestedBlocks(): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent('{'); + $this->parser->parseCurrentContext($tokenizerContext); + $tokenizerContext->appendCurrentContent('{'); + $this->parser->parseCurrentContext($tokenizerContext); + $tokenizerContext->appendCurrentContent('}'); + + // Act + $result = $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $this->assertInstanceOf(BlockToken::class, $result); + $this->assertInstanceOf(BlockToken::class, $tokenizerContext->getCurrentBlockToken()); + } + + public function testParseCurrentContextWithSpace(): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent(' '); + + // Act + $result = $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $this->assertNull($result); + } + + public function testParseCurrentContextWithInvalidContent(): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent('test'); + + // Act + $result = $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $this->assertNull($result); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/CommentParserTest.php b/tests/TestSuite/Tokenizer/Parser/CommentParserTest.php new file mode 100644 index 0000000..37365c3 --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/CommentParserTest.php @@ -0,0 +1,125 @@ +parser = new CommentParser(); + } + + public function testGetHandledTokenClass(): void + { + $this->assertEquals(CommentToken::class, $this->parser->getHandledTokenClass()); + } + + public function validCommentProvider(): array + { + return [ + 'simple comment' => ['/* comment */', 'comment'], + 'multi-line comment' => ["/*\n * multi-line\n * comment\n */", "multi-line\ncomment"], + 'empty comment' => ['/**/', ''], + 'comment with special chars' => ['/* @import "style.css"; */', '@import "style.css";'], + ]; + } + + /** + * @dataProvider validCommentProvider + */ + public function testParsesValidComment(string $content, string $expectedValue): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(CommentToken::class, $token); + $this->assertEquals($expectedValue, $token->getValue()); + } + + public function testIgnoresNonCommentContent(): void + { + // Arrange + $content = 'not-a-comment'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } + + public function testHandlesCommentWithSpaces(): void + { + // Arrange + $content = ' /* comment */ '; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(CommentToken::class, $token); + $this->assertEquals('comment', $token->getValue()); + } + + public function testHandlesUnclosedComment(): void + { + // Arrange + $content = '/* unclosed comment'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/EndOfLineParserTest.php b/tests/TestSuite/Tokenizer/Parser/EndOfLineParserTest.php new file mode 100644 index 0000000..5a6314d --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/EndOfLineParserTest.php @@ -0,0 +1,70 @@ +parser = new EndOfLineParser(); + } + + public function testGetHandledTokenClass(): void + { + $this->assertEquals(WhitespaceToken::class, $this->parser->getHandledTokenClass()); + } + + public function endOfLineProvider(): array + { + return [ + 'newline' => ["\n", true], + 'carriage return' => ["\r", false], + 'carriage return newline' => ["\r\n", true], + 'non end of line' => ['a', false], + 'space' => [' ', false], + 'tab' => ["\t", false], + ]; + } + + /** + * @dataProvider endOfLineProvider + */ + public function testParseCurrentContext(string $char, bool $shouldIncrementLine): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $initialLine = $tokenizerContext->getCurrentPosition()->getLine(); + + // Act + $tokenizerContext->appendCurrentContent($char); + $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $expectedLine = $initialLine + ($shouldIncrementLine ? 1 : 0); + $this->assertEquals($expectedLine, $tokenizerContext->getCurrentPosition()->getLine()); + } + + public function testParseCurrentContextReturnsNull(): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->appendCurrentContent("\n"); + + // Act + $result = $this->parser->parseCurrentContext($tokenizerContext); + + // Assert + $this->assertNull($result); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/PropertyParserTest.php b/tests/TestSuite/Tokenizer/Parser/PropertyParserTest.php new file mode 100644 index 0000000..599d82e --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/PropertyParserTest.php @@ -0,0 +1,135 @@ +parser = new PropertyParser(); + } + + public function testGetHandledTokenClass(): void + { + $this->assertEquals(PropertyToken::class, $this->parser->getHandledTokenClass()); + } + + public function validPropertiesProvider(): array + { + return [ + 'simple property' => ['color: red;', 'color', 'red'], + 'property with spaces' => ['margin : 10px;', 'margin', '10px'], + 'property with multiple values' => ['padding: 10px 20px;', 'padding', '10px 20px'], + 'property with important' => ['color: blue !important;', 'color', 'blue !important'], + 'custom property' => ['--custom-prop: value;', '--custom-prop', 'value'], + 'vendor prefix property' => ['-webkit-transform: rotate(45deg);', '-webkit-transform', 'rotate(45deg)'], + ]; + } + + /** + * @dataProvider validPropertiesProvider + */ + public function testParsesValidProperties(string $content, string $expectedName, string $expectedValue): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 0)); + $tokenizerContext->setCurrentBlockToken($blockToken); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertNotEmpty($tokens); + $lastToken = end($tokens); + $this->assertInstanceOf(PropertyToken::class, $lastToken); + $this->assertEquals($expectedName, $lastToken->getName()); + $this->assertEquals($expectedValue, $lastToken->getValue()); + } + + public function testIgnoresNonPropertyContent(): void + { + // Arrange + $content = 'not-a-property'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } + + public function testIgnoresContentOutsideBlock(): void + { + // Arrange + $content = 'color: red;'; + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->setCurrentBlockToken(null); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } + + public function testHandlesPropertyEndingWithBlockEnd(): void + { + // Arrange + $content = 'color: red}'; + $tokenizerContext = new TokenizerContext(); + $blockToken = new BlockToken([], new Position(1, 0), new Position(1, 0)); + $tokenizerContext->setCurrentBlockToken($blockToken); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertNotEmpty($tokens); + $lastToken = end($tokens); + $this->assertInstanceOf(PropertyToken::class, $lastToken); + $this->assertEquals('color', $lastToken->getName()); + $this->assertEquals('red', $lastToken->getValue()); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/SelectorParserTest.php b/tests/TestSuite/Tokenizer/Parser/SelectorParserTest.php new file mode 100644 index 0000000..e0f50b8 --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/SelectorParserTest.php @@ -0,0 +1,187 @@ +parser = new SelectorParser(); + } + + public function testGetHandledTokenClass(): void + { + $this->assertEquals(SelectorToken::class, $this->parser->getHandledTokenClass()); + } + + public function validSelectorsProvider(): array + { + return [ + 'simple class selector' => ['.class-name {', '.class-name'], + 'id selector' => ['#my-id {', '#my-id'], + 'element selector' => ['div {', 'div'], + 'multiple classes' => ['.class1.class2 {', '.class1.class2'], + 'element with class' => ['div.class {', 'div.class'], + 'element with id' => ['div#id {', 'div#id'], + 'pseudo class' => ['a:hover {', 'a:hover'], + 'complex selector' => ['div.class-name:hover {', 'div.class-name:hover'], + 'kebab case class' => ['.my-class-name {', '.my-class-name'], + 'underscore in class' => ['.my_class_name {', '.my_class_name'], + 'numbers in selector' => ['.class123 {', '.class123'], + 'button with pseudo class' => ['.button.dropdown::after {', '.button.dropdown::after'], + 'attribute selector' => ['[data-test="value"] {', '[data-test="value"]'], + 'multiple selectors' => ['div, .class, #id {', 'div, .class, #id'], + 'child combinator' => ['div > p {', 'div > p'], + 'adjacent sibling' => ['div + p {', 'div + p'], + 'general sibling' => ['div ~ p {', 'div ~ p'], + ]; + } + + /** + * @dataProvider validSelectorsProvider + */ + public function testParsesValidSelectors(string $content, string $expectedValue): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(SelectorToken::class, $token); + $this->assertEquals($expectedValue, $token->getValue()); + } + + public function testIgnoresNonSelectorContent(): void + { + // Arrange + $content = 'not-a-selector'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } + + public function testHandlesSelectorWithSpaces(): void + { + // Arrange + $content = ' .class-name {'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(SelectorToken::class, $token); + $this->assertEquals('.class-name', $token->getValue()); + } + + public function testHandlesSelectorWithNewlines(): void + { + // Arrange + $content = "\n.class-name\n{"; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(SelectorToken::class, $token); + $this->assertEquals('.class-name', $token->getValue()); + } + + public function testHandlesSelectorWithMultipleSpaces(): void + { + // Arrange + $content = 'div p {'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(SelectorToken::class, $token); + $this->assertEquals('div p', $token->getValue()); + } + + public function testHandlesSelectorWithParentheses(): void + { + // Arrange + $content = ':not(.class) {'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertCount(1, $tokens); + $token = $tokens[0]; + $this->assertInstanceOf(SelectorToken::class, $token); + $this->assertEquals(':not(.class)', $token->getValue()); + } +} diff --git a/tests/TestSuite/Tokenizer/Parser/WhitespaceParserTest.php b/tests/TestSuite/Tokenizer/Parser/WhitespaceParserTest.php new file mode 100644 index 0000000..8388d57 --- /dev/null +++ b/tests/TestSuite/Tokenizer/Parser/WhitespaceParserTest.php @@ -0,0 +1,79 @@ +parser = new WhitespaceParser(); + } + + public function whitespaceProvider(): array + { + return [ + 'single space' => ["\n f", ' ', new Position(2, 1), new Position(2, 2)], + 'multiple spaces' => ["\n f", ' ', new Position(2, 1), new Position(2, 2)], + ]; + } + + /** + * @dataProvider whitespaceProvider + */ + public function testParsesWhitespace(string $content, string $expectedValue, Position $expectedStart, Position $expectedEnd): void + { + // Arrange + $tokenizerContext = new TokenizerContext(); + $tokenizerContext->incrementLine(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertNotEmpty($tokens); + $lastToken = end($tokens); + $this->assertInstanceOf(WhitespaceToken::class, $lastToken); + $this->assertEquals($expectedValue, $lastToken->getValue()); + + $this->assertEquals($expectedStart, $lastToken->getStart()); + $this->assertEquals($expectedEnd, $lastToken->getEnd()); + } + + public function testIgnoresNonWhitespaceContent(): void + { + // Arrange + $content = 'abc123'; + $tokenizerContext = new TokenizerContext(); + $tokens = []; + + // Act + foreach (str_split($content) as $char) { + $tokenizerContext->appendCurrentContent($char); + $token = $this->parser->parseCurrentContext($tokenizerContext); + if ($token) { + $tokens[] = $token; + } + } + + // Assert + $this->assertEmpty($tokens); + } +} diff --git a/tests/TestSuite/Tokenizer/TokenizerContextTest.php b/tests/TestSuite/Tokenizer/TokenizerContextTest.php new file mode 100644 index 0000000..5974618 --- /dev/null +++ b/tests/TestSuite/Tokenizer/TokenizerContextTest.php @@ -0,0 +1,134 @@ +context = new TokenizerContext(); + } + + public function testInitialState(): void + { + $this->assertEmpty($this->context->getCurrentContent()); + $this->assertNull($this->context->getCurrentToken()); + $this->assertEquals(new Position(1, 1), $this->context->getCurrentPosition()); + } + + public function contentManipulationProvider(): array + { + return [ + 'simple content' => ['test', 'ing', 'testing'], + 'with spaces' => ['hello ', 'world', 'hello world'], + 'empty append' => ['content', '', 'content'], + 'special chars' => ['test:', '123', 'test:123'], + ]; + } + + /** + * @dataProvider contentManipulationProvider + */ + public function testContentManipulation(string $content, string $appendContent, string $expectedResult): void + { + $this->context->appendCurrentContent($content); + $this->assertEquals($content, $this->context->getCurrentContent()); + + $this->context->appendCurrentContent($appendContent); + $this->assertEquals($expectedResult, $this->context->getCurrentContent()); + + $this->context->resetCurrentContent(); + $this->assertEmpty($this->context->getCurrentContent()); + } + + public function getNthLastCharsProvider(): array + { + return [ + 'one char' => ['t', 1, 0, 't'], + 'single last char' => ['testing', 1, 0, 'g'], + 'multiple chars' => ['testing', 3, 0, 'ing'], + 'with offset' => ['testing', 2, 1, 'in'], + 'empty content' => ['', 1, 0, null], + 'length greater than content' => ['test', 5, 0, null], + 'offset beyond content' => ['test', 1, 4, null], + ]; + } + + /** + * @dataProvider getNthLastCharsProvider + */ + public function testGetNthLastChars(string $content, int $length, int $offset, ?string $expected): void + { + $this->context->appendCurrentContent($content); + $this->assertEquals($content, $this->context->getCurrentContent()); + $this->assertEquals($expected, $this->context->getNthLastChars($length, $offset)); + } + + public function testGetLastChar(): void + { + $this->assertNull($this->context->getLastChar()); + + $this->context->appendCurrentContent('test'); + $this->assertEquals('t', $this->context->getLastChar()); + + $this->context->resetCurrentContent(); + $this->assertNull($this->context->getLastChar()); + } + + public function testTokenManipulation(): void + { + /** @var Token $mockToken */ + $mockToken = $this->getMockBuilder(Token::class)->getMock(); + + $this->assertNull($this->context->getCurrentToken()); + + $this->context->setCurrentToken($mockToken); + $this->assertSame($mockToken, $this->context->getCurrentToken()); + + $this->context->resetCurrentToken(); + $this->assertNull($this->context->getCurrentToken()); + } + + /** + * @dataProvider tokenAssertionProvider + */ + public function testAssertCurrentToken(?string $tokenClass, ?Token $currentToken, bool $expected): void + { + if ($currentToken !== null) { + $this->context->setCurrentToken($currentToken); + } + + $this->assertEquals($expected, $this->context->assertCurrentToken($tokenClass)); + } + + public function tokenAssertionProvider(): array + { + /** @var Token $mockToken */ + $mockToken = $this->getMockBuilder(Token::class)->getMock(); + + return [ + 'null token and null class' => [null, null, true], + 'null token with class' => [Token::class, null, false], + 'matching token and class' => [get_class($mockToken), $mockToken, true], + 'non-matching token and class' => ['OtherTokenClass', $mockToken, false], + ]; + } + + public function testCurrentPosition(): void + { + $initialPosition = $this->context->getCurrentPosition(); + $this->assertInstanceOf(Position::class, $initialPosition); + $this->assertEquals(1, $initialPosition->getLine()); + $this->assertEquals(1, $initialPosition->getColumn()); + } +} diff --git a/tests/TestSuite/Tokenizer/TokenizerTest.php b/tests/TestSuite/Tokenizer/TokenizerTest.php new file mode 100644 index 0000000..e15b459 --- /dev/null +++ b/tests/TestSuite/Tokenizer/TokenizerTest.php @@ -0,0 +1,431 @@ +tokenizer = new Tokenizer(new LintConfiguration()); + } + + public function testTokenizeInvalidUnclosedBlock() + { + // Arrange + $stream = $this->getStream('.button {'); + + // Act + $tokensOrErrors = iterator_to_array($this->tokenizer->tokenize($stream), false); + + // Assert + $expectedTokensOrErrors = [ + [ + 'type' => 'selector', + 'value' => '.button', + 'start' => [ + 'line' => 1, + 'column' => 1, + ], + 'end' => [ + 'line' => 1, + 'column' => 8, + ], + ], + [ + "type" => "block", + "value" => [], + "start" => [ + "line" => 1, + "column" => 8, + ], + "end" => [ + "line" => 1, + "column" => 10, + ], + ], + [ + 'key' => 'unclosed_token', + 'message' => 'block - Unclosed "block" detected', + 'start' => [ + 'line' => 1, + 'column' => 8, + ], + 'end' => [ + 'line' => 1, + 'column' => 10, + ], + ], + ]; + + $this->assertTokensOrErrorsEquals($expectedTokensOrErrors, $tokensOrErrors); + } + + public function testTokenizeInvalidUnexpectedBlockChar() + { + // Arrange + $stream = $this->getStream('.button { } }'); + + // Act + $tokensOrErrors = iterator_to_array($this->tokenizer->tokenize($stream), false); + + // Assert + $expectedTokensOrErrors = [ + [ + 'type' => 'selector', + 'value' => '.button', + 'start' => [ + 'line' => 1, + 'column' => 1, + ], + 'end' => [ + 'line' => 1, + 'column' => 8, + ], + ], + [ + 'type' => 'block', + 'value' => [], + 'start' => [ + 'line' => 1, + 'column' => 8, + ], + 'end' => [ + 'line' => 1, + 'column' => 11, + ], + ], + [ + 'key' => 'unexpected_character_end_of_content', + 'message' => "Unexpected character at end of content: \"}\"", + 'start' => [ + 'line' => 1, + 'column' => 12, + ], + 'end' => [ + 'line' => 1, + 'column' => 14, + ], + ], + ]; + + $this->assertTokensOrErrorsEquals($expectedTokensOrErrors, $tokensOrErrors); + } + + public function testTokenizeValidSelectorWithBlock() + { + // Arrange + $stream = $this->getStream('.button.dropdown::after { display: block; width: 10px; }'); + + // Act + $tokensOrErrors = iterator_to_array($this->tokenizer->tokenize($stream), false); + + // Assert + $expectedTokensOrErrors = [ + [ + 'type' => 'selector', + 'value' => '.button.dropdown::after', + 'start' => [ + 'line' => 1, + 'column' => 1, + ], + 'end' => [ + 'line' => 1, + 'column' => 24, + ], + ], + [ + 'type' => 'block', + 'value' => [ + [ + 'type' => 'property', + 'value' => [ + 'name' => 'display', + 'value' => 'block', + ], + 'start' => [ + 'line' => 1, + 'column' => 25, + ], + 'end' => [ + 'line' => 1, + 'column' => 40, + ], + ], + [ + 'type' => 'property', + 'value' => [ + 'name' => 'width', + 'value' => '10px', + ], + 'start' => [ + 'line' => 1, + 'column' => 41, + ], + 'end' => [ + 'line' => 1, + 'column' => 53, + ], + ], + ], + 'start' => [ + 'line' => 1, + 'column' => 24, + ], + 'end' => [ + 'line' => 1, + 'column' => 56, + ], + ], + ]; + + $this->assertTokensOrErrorsEquals($expectedTokensOrErrors, $tokensOrErrors); + } + + public function testTokenizeWithMultilineComments() + { + // Arrange + $stream = $this->getStream("/**\nThis is a comment\nThis is an another comment\n**/\n.button { display: block; }"); + + // Act + $tokensOrErrors = iterator_to_array($this->tokenizer->tokenize($stream), false); + + // Assert + $expectedTokensOrErrors = [ + [ + 'type' => 'comment', + 'value' => "This is a comment\nThis is an another comment\n", + 'start' => [ + 'line' => 1, + 'column' => 1, + ], + 'end' => [ + 'line' => 4, + 'column' => 4, + ], + ], + [ + 'type' => 'selector', + 'value' => '.button', + 'start' => [ + 'line' => 5, + 'column' => 1, + ], + 'end' => [ + 'line' => 5, + 'column' => 9, + ], + ], + [ + 'type' => 'block', + 'value' => [ + [ + 'type' => 'property', + 'value' => [ + 'name' => 'display', + 'value' => 'block', + ], + 'start' => [ + 'line' => 5, + 'column' => 10, + ], + 'end' => [ + 'line' => 5, + 'column' => 25, + ], + ], + ], + 'start' => [ + 'line' => 5, + 'column' => 9, + ], + 'end' => [ + 'line' => 5, + 'column' => 28, + ], + ], + ]; + + $this->assertTokensOrErrorsEquals($expectedTokensOrErrors, $tokensOrErrors); + } + + public function testTokenizeWithAtRuleInBlock() + { + // Arrange + $stream = $this->getStream("@layer utilities {\n @test utilities;\n}"); + + // Act + $tokensOrErrors = iterator_to_array($this->tokenizer->tokenize($stream), false); + + // Assert + $expectedTokensOrErrors = [ + [ + "type" => "at-rule", + "value" => [ + "name" => "layer", + "value" => "utilities", + 'isBlock' => true, + ], + "start" => [ + "line" => 1, + "column" => 1, + ], + "end" => [ + "line" => 1, + "column" => 17, + ], + ], + [ + 'type' => 'block', + 'value' => [ + [ + 'type' => 'whitespace', + 'value' => ' ', + 'start' => [ + 'line' => 2, + 'column' => 1, + ], + 'end' => [ + 'line' => 2, + 'column' => 3, + ], + ], + [ + 'type' => 'at-rule', + 'value' => [ + 'name' => 'test', + 'value' => 'utilities', + 'isBlock' => false, + ], + 'start' => [ + 'line' => 2, + 'column' => 3, + ], + 'end' => [ + 'line' => 2, + 'column' => 18, + ], + ], + ], + 'start' => [ + 'line' => 1, + 'column' => 17, + ], + 'end' => [ + 'line' => 3, + 'column' => 2, + ], + ], + ]; + + $this->assertTokensOrErrorsEquals($expectedTokensOrErrors, $tokensOrErrors); + } + + public function testTokenizeWithNestedBlocks() + { + // Arrange + $stream = $this->getStream('@layer utilities { .button { display: block; } }'); + + // Act + $tokensOrErrors = iterator_to_array($this->tokenizer->tokenize($stream), false); + + // Assert + $expectedTokensOrErrors = [ + [ + 'type' => 'at-rule', + 'value' => ['name' => 'layer', 'value' => 'utilities', 'isBlock' => true], + 'start' => [ + 'line' => 1, + 'column' => 1, + ], + 'end' => [ + 'line' => 1, + 'column' => 17, + ], + ], + [ + 'type' => 'block', + 'value' => [ + [ + 'type' => 'selector', + 'value' => '.button', + 'start' => [ + 'line' => 1, + 'column' => 18, + ], + 'end' => [ + 'line' => 1, + 'column' => 27, + ], + ], + [ + 'type' => 'block', + 'value' => [ + [ + 'type' => 'property', + 'value' => [ + 'name' => 'display', + 'value' => 'block', + ], + 'start' => [ + 'line' => 1, + 'column' => 28, + ], + 'end' => [ + 'line' => 1, + 'column' => 43, + ], + ], + ], + 'start' => [ + 'line' => 1, + 'column' => 27, + ], + 'end' => [ + 'line' => 1, + 'column' => 46, + ], + ], + ], + 'start' => [ + 'line' => 1, + 'column' => 17, + ], + 'end' => [ + 'line' => 1, + 'column' => 48, + ], + ], + ]; + + $this->assertTokensOrErrorsEquals($expectedTokensOrErrors, $tokensOrErrors); + } + + private function getStream(string $css): mixed + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $css); + rewind($stream); + return $stream; + } + + private function assertTokensOrErrorsEquals(array $expected, array $actual) + { + $this->assertCount(count($expected), $actual, json_encode($actual, JSON_PRETTY_PRINT)); + foreach ($actual as $key => $tokenOrError) { + $this->assertEquals( + $expected[$key], + $tokenOrError->jsonSerialize(), + "Token or error at index $key does not match expected value." + ); + } + } +} diff --git a/tests/fixtures/not_valid.css b/tests/fixtures/not_valid.css index 3a2241a..9aa141d 100644 --- a/tests/fixtures/not_valid.css +++ b/tests/fixtures/not_valid.css @@ -1,16 +1,5 @@ .button.drodown::after { display: block; - width: 0; - height: 0; - border: inset 0.4em; - content: ""; - border-bottom-width: 0; bordr-top-style: solid; - border-color: #fefefe transparent transparent; - position: relative; - top: 0.4em; - display: inline-block; - float: right; - margin-left: 1em; diff --git a/tests/fixtures/valid.css b/tests/fixtures/valid.css index fb399c3..dec37d8 100644 --- a/tests/fixtures/valid.css +++ b/tests/fixtures/valid.css @@ -2,6 +2,17 @@ --border-radius: 3px; } +@font-face { + font-family: Poppins; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/poppins/v23/pxiEyp8kv8JHgFVrJJbecmNE.woff2) + format("woff2"); + unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, + U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09; +} + .button.dropdown::after { display: block; width: 0; diff --git a/tools/composer.json b/tools/composer.json index 274c832..9f1c06c 100644 --- a/tools/composer.json +++ b/tools/composer.json @@ -2,10 +2,10 @@ "require": { "friendsofphp/php-cs-fixer": "^3.75", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", "staabm/annotate-pull-request-from-checkstyle": "^1.8", - "rector/rector": "^1.2" + "rector/rector": "^2.0" }, "config": { "allow-plugins": {