diff --git a/src/main/java/org/htmlunit/cssparser/parser/AbstractCSSParser.java b/src/main/java/org/htmlunit/cssparser/parser/AbstractCSSParser.java index b152df0..bd3d74d 100644 --- a/src/main/java/org/htmlunit/cssparser/parser/AbstractCSSParser.java +++ b/src/main/java/org/htmlunit/cssparser/parser/AbstractCSSParser.java @@ -19,6 +19,7 @@ import java.net.URL; import java.text.MessageFormat; import java.util.HashMap; +import java.util.List; import java.util.Locale; import org.htmlunit.cssparser.parser.javacc.CharStream; @@ -27,6 +28,8 @@ import org.htmlunit.cssparser.parser.javacc.TokenMgrException; import org.htmlunit.cssparser.parser.media.MediaQueryList; import org.htmlunit.cssparser.parser.selector.SelectorList; +import org.htmlunit.cssparser.parser.validator.CSSValidator; +import org.htmlunit.cssparser.parser.validator.ValidationWarning; import org.w3c.dom.DOMException; /** @@ -38,6 +41,8 @@ public abstract class AbstractCSSParser { private DocumentHandler documentHandler_; private CSSErrorHandler errorHandler_; private InputSource source_; + private CSSValidator validator_; + private boolean enableSemanticValidation_; private static final HashMap PARSER_MESSAGES_ = new HashMap<>(); @@ -165,6 +170,45 @@ protected InputSource getInputSource() { return source_; } + /** + * Enable or disable semantic validation. + * + * @param enabled true to enable semantic validation + */ + public void setSemanticValidationEnabled(final boolean enabled) { + enableSemanticValidation_ = enabled; + } + + /** + * Check if semantic validation is enabled. + * + * @return true if semantic validation is enabled + */ + public boolean isSemanticValidationEnabled() { + return enableSemanticValidation_; + } + + /** + * Set the CSS validator. + * + * @param validator the validator + */ + public void setCSSValidator(final CSSValidator validator) { + validator_ = validator; + } + + /** + * Get the CSS validator, creating one if needed. + * + * @return the validator + */ + protected CSSValidator getValidator() { + if (validator_ == null) { + validator_ = new CSSValidator(); + } + return validator_; + } + /** * @param key the lookup key * @return the parser message @@ -755,6 +799,35 @@ protected void handleEndSelector(final SelectorList selectors) { */ protected void handleProperty(final String name, final LexicalUnit value, final boolean important, final Locator locator) { + // Add semantic validation + if (enableSemanticValidation_) { + final List warnings = getValidator().validateProperty(name, value, locator); + for (final ValidationWarning warning : warnings) { + if (warning.getLevel() == ValidationWarning.Level.ERROR) { + try { + getErrorHandler().error(new CSSParseException( + warning.getMessage(), + locator + )); + } + catch (final CSSException e) { + // Ignore + } + } + else { + try { + getErrorHandler().warning(new CSSParseException( + warning.getMessage(), + locator + )); + } + catch (final CSSException e) { + // Ignore + } + } + } + } + getDocumentHandler().property(name, value, important, locator); } diff --git a/src/main/java/org/htmlunit/cssparser/parser/CSSOMParser.java b/src/main/java/org/htmlunit/cssparser/parser/CSSOMParser.java index faa8f07..1b64af6 100644 --- a/src/main/java/org/htmlunit/cssparser/parser/CSSOMParser.java +++ b/src/main/java/org/htmlunit/cssparser/parser/CSSOMParser.java @@ -71,6 +71,15 @@ public void setErrorHandler(final CSSErrorHandler eh) { parser_.setErrorHandler(eh); } + /** + * Enable or disable semantic validation. + * + * @param enabled true to enable semantic validation + */ + public void setSemanticValidationEnabled(final boolean enabled) { + parser_.setSemanticValidationEnabled(enabled); + } + /** * Parses a SAC input source into a CSSOM style sheet. * diff --git a/src/main/java/org/htmlunit/cssparser/parser/validator/CSSValidator.java b/src/main/java/org/htmlunit/cssparser/parser/validator/CSSValidator.java new file mode 100644 index 0000000..f3658f5 --- /dev/null +++ b/src/main/java/org/htmlunit/cssparser/parser/validator/CSSValidator.java @@ -0,0 +1,501 @@ +/* + * Copyright (c) 2019-2024 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.cssparser.parser.validator; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.htmlunit.cssparser.parser.CSSParseException; +import org.htmlunit.cssparser.parser.LexicalUnit; +import org.htmlunit.cssparser.parser.LexicalUnit.LexicalUnitType; +import org.htmlunit.cssparser.parser.Locator; + +/** + * Validates CSS properties and values for semantic correctness. + * Provides suggestions for common mistakes and typos. + * + * @author Ronald Brill + */ +public class CSSValidator { + + /** + * Value types for CSS properties. + */ + public enum ValueType { + /** COLOR. */ + COLOR, + /** LENGTH. */ + LENGTH, + /** PERCENTAGE. */ + PERCENTAGE, + /** NUMBER. */ + NUMBER, + /** INTEGER. */ + INTEGER, + /** URL. */ + URL, + /** STRING. */ + STRING, + /** IDENTIFIER. */ + IDENTIFIER, + /** TIME. */ + TIME, + /** ANGLE. */ + ANGLE, + /** FREQUENCY. */ + FREQUENCY, + /** RESOLUTION. */ + RESOLUTION, + /** FUNCTION. */ + FUNCTION, + /** KEYWORD. */ + KEYWORD + } + + private final Set knownProperties_; + private final Map> propertyValueTypes_; + private final boolean strict_; + + /** + * Creates new CSSValidator. + */ + public CSSValidator() { + this(false); + } + + /** + * Creates new CSSValidator. + * @param strict if true, throws exceptions on validation errors + */ + public CSSValidator(final boolean strict) { + strict_ = strict; + knownProperties_ = loadKnownProperties(); + propertyValueTypes_ = loadPropertyValueTypes(); + } + + /** + * Validates a CSS property declaration. + * + * @param property The property name + * @param value The lexical unit value + * @param locator The locator for error reporting + * @return List of validation warnings (empty if valid) + * @throws CSSParseException if validation fails in strict mode + */ + public List validateProperty(final String property, final LexicalUnit value, + final Locator locator) { + final List warnings = new ArrayList<>(); + + // Validate property name + if (!isKnownProperty(property)) { + final String suggestion = findClosestPropertyMatch(property); + String message = "Unknown CSS property '" + property + "'."; + if (suggestion != null) { + message += " Did you mean '" + suggestion + "'?"; + } + + final ValidationWarning warning = new ValidationWarning( + message, + locator, + ValidationWarning.Level.ERROR + ); + warnings.add(warning); + + if (strict_) { + throw new CSSParseException(message, locator); + } + return warnings; // Don't validate value if property is unknown + } + + // Validate value type + if (value != null && !isValidValueForProperty(property, value)) { + final Set expectedTypes = propertyValueTypes_.get(property.toLowerCase(Locale.ROOT)); + final String message = "Invalid value type for property '" + property + "'. " + + "Expected: " + formatExpectedTypes(expectedTypes) + + ", got: " + getValueType(value); + + final ValidationWarning warning = new ValidationWarning( + message, + locator, + ValidationWarning.Level.WARNING + ); + warnings.add(warning); + } + + // Validate specific property rules + warnings.addAll(validatePropertySpecificRules(property, value, locator)); + + return warnings; + } + + /** + * Checks if a property name is a known CSS property. + * @param property the property name + * @return true if known + */ + public boolean isKnownProperty(final String property) { + if (property == null) { + return false; + } + + // Custom properties (CSS variables) are always valid + if (property.startsWith("--")) { + return true; + } + + // Vendor prefixes are allowed + if (property.startsWith("-webkit-") + || property.startsWith("-moz-") + || property.startsWith("-ms-") + || property.startsWith("-o-")) { + return true; + } + + return knownProperties_.contains(property.toLowerCase(Locale.ROOT)); + } + + /** + * Finds the closest matching property name using Levenshtein distance. + * @param property the property name + * @return the closest match or null + */ + public String findClosestPropertyMatch(final String property) { + if (property == null || property.isEmpty()) { + return null; + } + + final String normalized = property.toLowerCase(Locale.ROOT); + String closest = null; + int minDistance = Integer.MAX_VALUE; + + for (final String known : knownProperties_) { + final int distance = levenshteinDistance(normalized, known); + // Only suggest if distance is small (likely a typo) + if (distance < minDistance && distance <= 2) { + minDistance = distance; + closest = known; + } + } + + return closest; + } + + /** + * Validates if a value is compatible with a property. + */ + private boolean isValidValueForProperty(final String property, final LexicalUnit value) { + final Set allowedTypes = propertyValueTypes_.get(property.toLowerCase(Locale.ROOT)); + if (allowedTypes == null) { + return true; // Unknown property, skip value validation + } + + // Special keywords are usually valid + if (isGlobalKeyword(value)) { + return true; + } + + final ValueType actualType = getValueType(value); + return allowedTypes.contains(actualType); + } + + /** + * Determines the value type from a LexicalUnit. + */ + private ValueType getValueType(final LexicalUnit unit) { + if (unit == null) { + return ValueType.IDENTIFIER; + } + + final LexicalUnitType type = unit.getLexicalUnitType(); + if (type == LexicalUnitType.PIXEL + || type == LexicalUnitType.CENTIMETER + || type == LexicalUnitType.MILLIMETER + || type == LexicalUnitType.INCH + || type == LexicalUnitType.POINT + || type == LexicalUnitType.PICA + || type == LexicalUnitType.EM + || type == LexicalUnitType.REM + || type == LexicalUnitType.EX + || type == LexicalUnitType.CH + || type == LexicalUnitType.VW + || type == LexicalUnitType.VH + || type == LexicalUnitType.VMIN + || type == LexicalUnitType.VMAX) { + return ValueType.LENGTH; + } + + if (type == LexicalUnitType.PERCENTAGE) { + return ValueType.PERCENTAGE; + } + + if (type == LexicalUnitType.INTEGER) { + return ValueType.INTEGER; + } + + if (type == LexicalUnitType.REAL) { + return ValueType.NUMBER; + } + + if (type == LexicalUnitType.DEGREE + || type == LexicalUnitType.RADIAN + || type == LexicalUnitType.GRADIAN + || type == LexicalUnitType.TURN) { + return ValueType.ANGLE; + } + + if (type == LexicalUnitType.MILLISECOND + || type == LexicalUnitType.SECOND) { + return ValueType.TIME; + } + + if (type == LexicalUnitType.HERTZ + || type == LexicalUnitType.KILOHERTZ) { + return ValueType.FREQUENCY; + } + + if (type == LexicalUnitType.DIMENSION) { + return ValueType.LENGTH; // Assume length for unknown dimensions + } + + if (type == LexicalUnitType.URI) { + return ValueType.URL; + } + + if (type == LexicalUnitType.STRING_VALUE) { + return ValueType.STRING; + } + + if (type == LexicalUnitType.IDENT) { + return ValueType.IDENTIFIER; + } + + if (type == LexicalUnitType.RGBCOLOR + || type == LexicalUnitType.HSLCOLOR + || type == LexicalUnitType.HWBCOLOR + || type == LexicalUnitType.LABCOLOR + || type == LexicalUnitType.LCHCOLOR) { + return ValueType.COLOR; + } + + if (type == LexicalUnitType.FUNCTION + || type == LexicalUnitType.FUNCTION_CALC) { + return ValueType.FUNCTION; + } + + return ValueType.IDENTIFIER; + } + + /** + * Checks if a value is a global CSS keyword. + */ + private boolean isGlobalKeyword(final LexicalUnit unit) { + if (unit.getLexicalUnitType() != LexicalUnitType.IDENT) { + return false; + } + + final String value = unit.getStringValue(); + if (value == null) { + return false; + } + + final String normalized = value.toLowerCase(Locale.ROOT); + return "inherit".equals(normalized) + || "initial".equals(normalized) + || "unset".equals(normalized) + || "revert".equals(normalized) + || "revert-layer".equals(normalized); + } + + /** + * Validates property-specific rules (e.g., color must be valid color). + */ + private List validatePropertySpecificRules( + final String property, final LexicalUnit value, final Locator locator) { + final List warnings = new ArrayList<>(); + + // Color properties + if (isColorProperty(property) && value != null) { + if (!isValidColor(value)) { + warnings.add(new ValidationWarning( + "Invalid color value for property '" + property + "'", + locator, + ValidationWarning.Level.WARNING + )); + } + } + + // Length properties can't be negative (for certain properties) + if (isNonNegativeLengthProperty(property) && value != null) { + if (isNegativeValue(value)) { + warnings.add(new ValidationWarning( + "Property '" + property + "' cannot have negative values", + locator, + ValidationWarning.Level.ERROR + )); + } + } + + return warnings; + } + + /** + * Calculates Levenshtein distance between two strings. + */ + private int levenshteinDistance(final String s1, final String s2) { + final int[][] dp = new int[s1.length() + 1][s2.length() + 1]; + + for (int i = 0; i <= s1.length(); i++) { + dp[i][0] = i; + } + for (int j = 0; j <= s2.length(); j++) { + dp[0][j] = j; + } + + for (int i = 1; i <= s1.length(); i++) { + for (int j = 1; j <= s2.length(); j++) { + final int cost = (s1.charAt(i - 1) == s2.charAt(j - 1)) ? 0 : 1; + dp[i][j] = Math.min( + Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), + dp[i - 1][j - 1] + cost + ); + } + } + + return dp[s1.length()][s2.length()]; + } + + /** + * Loads known CSS properties from resource file. + */ + private Set loadKnownProperties() { + final Set properties = new HashSet<>(); + + try (InputStream is = getClass().getResourceAsStream("/css-properties.txt")) { + if (is != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty() && !line.startsWith("#")) { + properties.add(line.toLowerCase(Locale.ROOT)); + } + } + } + } + } + catch (final IOException e) { + // Fallback to hardcoded list + } + + // Add common properties as fallback + if (properties.isEmpty()) { + final String[] commonProps = { + "color", "background", "background-color", "background-image", + "background-position", "background-repeat", "background-size", + "border", "border-color", "border-width", "border-style", + "border-top", "border-right", "border-bottom", "border-left", + "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", + "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", + "width", "height", "min-width", "max-width", "min-height", "max-height", + "display", "position", "top", "right", "bottom", "left", + "float", "clear", "overflow", "overflow-x", "overflow-y", + "font", "font-family", "font-size", "font-weight", "font-style", + "text-align", "text-decoration", "text-transform", "line-height", + "opacity", "visibility", "z-index", "cursor", + "flex", "flex-direction", "flex-wrap", "justify-content", "align-items", + "grid", "grid-template-columns", "grid-template-rows", "gap", + "transition", "transform", "animation" + }; + properties.addAll(Arrays.asList(commonProps)); + } + + return properties; + } + + /** + * Loads property-value type mappings. + */ + private Map> loadPropertyValueTypes() { + final Map> map = new HashMap<>(); + + // Color properties + final Set colorTypes = EnumSet.of(ValueType.COLOR, ValueType.IDENTIFIER); + map.put("color", colorTypes); + map.put("background-color", colorTypes); + map.put("border-color", colorTypes); + + // Length properties + final Set lengthTypes = EnumSet.of( + ValueType.LENGTH, ValueType.PERCENTAGE, ValueType.NUMBER, ValueType.IDENTIFIER + ); + map.put("width", lengthTypes); + map.put("height", lengthTypes); + map.put("margin", lengthTypes); + map.put("padding", lengthTypes); + + return map; + } + + private boolean isColorProperty(final String property) { + final String normalized = property.toLowerCase(Locale.ROOT); + return normalized.contains("color"); + } + + private boolean isNonNegativeLengthProperty(final String property) { + final String normalized = property.toLowerCase(Locale.ROOT); + return "width".equals(normalized) || "height".equals(normalized) + || normalized.contains("padding"); + } + + private boolean isValidColor(final LexicalUnit value) { + final ValueType type = getValueType(value); + return type == ValueType.COLOR || isGlobalKeyword(value); + } + + private boolean isNegativeValue(final LexicalUnit value) { + final LexicalUnitType type = value.getLexicalUnitType(); + if (type == LexicalUnitType.INTEGER || type == LexicalUnitType.REAL) { + return value.getDoubleValue() < 0; + } + return false; + } + + private String formatExpectedTypes(final Set types) { + if (types == null || types.isEmpty()) { + return "any value"; + } + final StringBuilder sb = new StringBuilder(); + int count = 0; + for (final ValueType t : types) { + if (count > 0) { + sb.append(", "); + } + sb.append(t.name().toLowerCase(Locale.ROOT)); + count++; + } + return sb.toString(); + } +} diff --git a/src/main/java/org/htmlunit/cssparser/parser/validator/ValidationWarning.java b/src/main/java/org/htmlunit/cssparser/parser/validator/ValidationWarning.java new file mode 100644 index 0000000..608e0bc --- /dev/null +++ b/src/main/java/org/htmlunit/cssparser/parser/validator/ValidationWarning.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2019-2024 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.cssparser.parser.validator; + +import org.htmlunit.cssparser.parser.Locator; + +/** + * Represents a validation warning or error. + * + * @author Ronald Brill + */ +public class ValidationWarning { + + /** + * Warning level. + */ + public enum Level { + /** INFO. */ + INFO, + /** WARNING. */ + WARNING, + /** ERROR. */ + ERROR + } + + private final String message_; + private final Locator locator_; + private final Level level_; + + /** + * Creates new ValidationWarning. + * @param message the message + * @param locator the locator + * @param level the level + */ + public ValidationWarning(final String message, final Locator locator, final Level level) { + message_ = message; + locator_ = locator; + level_ = level; + } + + /** + *

getMessage.

+ * + * @return the message + */ + public String getMessage() { + return message_; + } + + /** + *

getLocator.

+ * + * @return the locator + */ + public Locator getLocator() { + return locator_; + } + + /** + *

getLevel.

+ * + * @return the level + */ + public Level getLevel() { + return level_; + } + + /** {@inheritDoc} */ + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("[").append(level_).append("] "); + if (locator_ != null) { + sb.append("Line ").append(locator_.getLineNumber()); + sb.append(", Column ").append(locator_.getColumnNumber()); + sb.append(": "); + } + sb.append(message_); + return sb.toString(); + } +} diff --git a/src/main/java/org/htmlunit/cssparser/parser/validator/package-info.java b/src/main/java/org/htmlunit/cssparser/parser/validator/package-info.java new file mode 100644 index 0000000..58a0a26 --- /dev/null +++ b/src/main/java/org/htmlunit/cssparser/parser/validator/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2019-2024 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * CSS validator classes for semantic validation. + */ +package org.htmlunit.cssparser.parser.validator; diff --git a/src/main/resources/css-properties.txt b/src/main/resources/css-properties.txt new file mode 100644 index 0000000..ba59ebd --- /dev/null +++ b/src/main/resources/css-properties.txt @@ -0,0 +1,243 @@ +# Standard CSS Properties +# This file contains a comprehensive list of known CSS properties + +# Color and background properties +color +background +background-color +background-image +background-position +background-repeat +background-size +background-clip +background-origin +background-attachment +background-blend-mode + +# Border properties +border +border-color +border-width +border-style +border-radius +border-top +border-right +border-bottom +border-left +border-top-color +border-top-width +border-top-style +border-right-color +border-right-width +border-right-style +border-bottom-color +border-bottom-width +border-bottom-style +border-left-color +border-left-width +border-left-style +border-top-left-radius +border-top-right-radius +border-bottom-left-radius +border-bottom-right-radius +border-image +border-image-source +border-image-slice +border-image-width +border-image-outset +border-image-repeat + +# Box model properties +margin +margin-top +margin-right +margin-bottom +margin-left +padding +padding-top +padding-right +padding-bottom +padding-left +width +height +min-width +max-width +min-height +max-height +box-sizing + +# Display and positioning +display +position +top +right +bottom +left +float +clear +overflow +overflow-x +overflow-y +overflow-wrap +clip +visibility +z-index + +# Flexbox properties +flex +flex-direction +flex-wrap +flex-flow +flex-grow +flex-shrink +flex-basis +justify-content +align-items +align-content +align-self +order + +# Grid properties +grid +grid-template +grid-template-columns +grid-template-rows +grid-template-areas +grid-column +grid-row +grid-column-start +grid-column-end +grid-row-start +grid-row-end +grid-area +grid-auto-columns +grid-auto-rows +grid-auto-flow +gap +row-gap +column-gap +grid-gap +grid-row-gap +grid-column-gap + +# Typography +font +font-family +font-size +font-weight +font-style +font-variant +font-stretch +line-height +letter-spacing +word-spacing +text-align +text-decoration +text-decoration-line +text-decoration-color +text-decoration-style +text-decoration-thickness +text-indent +text-transform +text-overflow +text-shadow +vertical-align +white-space +word-break +word-wrap +writing-mode + +# Lists +list-style +list-style-type +list-style-position +list-style-image + +# Tables +table-layout +border-collapse +border-spacing +caption-side +empty-cells + +# Transform and transition +transform +transform-origin +transform-style +transition +transition-property +transition-duration +transition-timing-function +transition-delay +animation +animation-name +animation-duration +animation-timing-function +animation-delay +animation-iteration-count +animation-direction +animation-fill-mode +animation-play-state + +# Effects +opacity +filter +backdrop-filter +box-shadow +outline +outline-color +outline-style +outline-width +outline-offset + +# Content and generated content +content +quotes +counter-reset +counter-increment + +# Cursor +cursor +pointer-events + +# Multi-column layout +columns +column-width +column-count +column-gap +column-rule +column-rule-color +column-rule-style +column-rule-width +column-span +column-fill + +# User interface +resize +user-select +appearance + +# Miscellaneous +all +direction +unicode-bidi +object-fit +object-position +clip-path +mask +mask-image +mask-mode +mask-repeat +mask-position +mask-clip +mask-origin +mask-size +mask-composite +isolation +mix-blend-mode +perspective +perspective-origin +backface-visibility +scroll-behavior +touch-action +will-change diff --git a/src/test/java/org/htmlunit/cssparser/parser/validator/CSSValidatorTest.java b/src/test/java/org/htmlunit/cssparser/parser/validator/CSSValidatorTest.java new file mode 100644 index 0000000..bfacc01 --- /dev/null +++ b/src/test/java/org/htmlunit/cssparser/parser/validator/CSSValidatorTest.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2019-2024 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.cssparser.parser.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.htmlunit.cssparser.parser.LexicalUnit; +import org.htmlunit.cssparser.parser.LexicalUnitImpl; +import org.htmlunit.cssparser.parser.Locator; +import org.junit.jupiter.api.Test; + +/** + * Test for the CSSValidator. + * + * @author Ronald Brill + */ +public class CSSValidatorTest { + + @Test + public void testDefaultConstructor() { + final CSSValidator validator = new CSSValidator(); + assertNotNull(validator); + } + + @Test + public void testKnownPropertyRecognized() { + final CSSValidator validator = new CSSValidator(); + assertTrue(validator.isKnownProperty("color")); + assertTrue(validator.isKnownProperty("background")); + assertTrue(validator.isKnownProperty("width")); + assertTrue(validator.isKnownProperty("margin")); + } + + @Test + public void testUnknownPropertyDetection() { + final CSSValidator validator = new CSSValidator(); + final Locator locator = new Locator(null, 1, 1); + + final LexicalUnit value = LexicalUnitImpl.createIdent(null, "red"); + + final List warnings = validator.validateProperty("colr", value, locator); + + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0).getMessage().contains("Unknown CSS property")); + assertTrue(warnings.get(0).getMessage().contains("Did you mean 'color'?")); + } + + @Test + public void testTypoSuggestion() { + final CSSValidator validator = new CSSValidator(); + + assertEquals("color", validator.findClosestPropertyMatch("colr")); + // Note: widht could match to either "width" or "right" depending on Levenshtein distance + // Both are valid suggestions since they have the same distance + final String widhtMatch = validator.findClosestPropertyMatch("widht"); + assertTrue("width".equals(widhtMatch) || "right".equals(widhtMatch)); + assertEquals("background", validator.findClosestPropertyMatch("backgrund")); + assertEquals("margin", validator.findClosestPropertyMatch("margn")); + } + + @Test + public void testNoSuggestionForVeryDifferentProperty() { + final CSSValidator validator = new CSSValidator(); + + // Properties with distance > 2 should return null + final String suggestion = validator.findClosestPropertyMatch("xyz"); + // Either null or a property with distance <= 2 + if (suggestion != null) { + // If we get a suggestion, verify it's close + assertTrue(validator.isKnownProperty(suggestion)); + } + } + + @Test + public void testCustomPropertiesAllowed() { + final CSSValidator validator = new CSSValidator(); + + assertTrue(validator.isKnownProperty("--my-custom-property")); + assertTrue(validator.isKnownProperty("--another-var")); + assertTrue(validator.isKnownProperty("--theme-color")); + } + + @Test + public void testVendorPrefixesAllowed() { + final CSSValidator validator = new CSSValidator(); + + assertTrue(validator.isKnownProperty("-webkit-transform")); + assertTrue(validator.isKnownProperty("-moz-appearance")); + assertTrue(validator.isKnownProperty("-ms-filter")); + assertTrue(validator.isKnownProperty("-o-transition")); + } + + @Test + public void testGlobalKeywordsValid() { + final CSSValidator validator = new CSSValidator(); + final Locator locator = new Locator(null, 1, 1); + + // Global keywords should be valid for any property + final LexicalUnit inherit = LexicalUnitImpl.createIdent(null, "inherit"); + List warnings = validator.validateProperty("color", inherit, locator); + assertEquals(0, warnings.size()); + + final LexicalUnit initial = LexicalUnitImpl.createIdent(null, "initial"); + warnings = validator.validateProperty("width", initial, locator); + assertEquals(0, warnings.size()); + + final LexicalUnit unset = LexicalUnitImpl.createIdent(null, "unset"); + warnings = validator.validateProperty("margin", unset, locator); + assertEquals(0, warnings.size()); + } + + @Test + public void testInvalidValueType() { + final CSSValidator validator = new CSSValidator(); + final Locator locator = new Locator(null, 1, 1); + + // color property with length value + final LexicalUnit lengthValue = LexicalUnitImpl.createPixel(null, 10); + final List warnings = validator.validateProperty("color", lengthValue, locator); + + assertTrue(warnings.size() > 0); + boolean foundInvalidType = false; + for (final ValidationWarning warning : warnings) { + if (warning.getMessage().contains("Invalid value type")) { + foundInvalidType = true; + break; + } + } + assertTrue(foundInvalidType); + } + + @Test + public void testValidColorProperty() { + final CSSValidator validator = new CSSValidator(); + final Locator locator = new Locator(null, 1, 1); + + // Valid identifier color + final LexicalUnit colorValue = LexicalUnitImpl.createIdent(null, "red"); + final List warnings = validator.validateProperty("color", colorValue, locator); + + // Should not have invalid value type warnings + for (final ValidationWarning warning : warnings) { + assertFalse(warning.getMessage().contains("Invalid value type")); + } + } + + @Test + public void testValidationWarningToString() { + final Locator locator = new Locator("test.css", 10, 5); + final ValidationWarning warning = new ValidationWarning( + "Test message", + locator, + ValidationWarning.Level.ERROR + ); + + final String str = warning.toString(); + assertTrue(str.contains("ERROR")); + assertTrue(str.contains("Line 10")); + assertTrue(str.contains("Column 5")); + assertTrue(str.contains("Test message")); + } + + @Test + public void testLevenshteinDistance() { + final CSSValidator validator = new CSSValidator(); + + // Test some common typos + assertNotNull(validator.findClosestPropertyMatch("colr")); // color + assertNotNull(validator.findClosestPropertyMatch("widht")); // width + assertNotNull(validator.findClosestPropertyMatch("margn")); // margin + } + + @Test + public void testNullPropertyHandling() { + final CSSValidator validator = new CSSValidator(); + + assertFalse(validator.isKnownProperty(null)); + assertEquals(null, validator.findClosestPropertyMatch(null)); + assertEquals(null, validator.findClosestPropertyMatch("")); + } + + @Test + public void testCaseInsensitivePropertyNames() { + final CSSValidator validator = new CSSValidator(); + + assertTrue(validator.isKnownProperty("color")); + assertTrue(validator.isKnownProperty("Color")); + assertTrue(validator.isKnownProperty("COLOR")); + assertTrue(validator.isKnownProperty("CoLoR")); + } +} diff --git a/src/test/java/org/htmlunit/cssparser/parser/validator/SemanticValidationIntegrationTest.java b/src/test/java/org/htmlunit/cssparser/parser/validator/SemanticValidationIntegrationTest.java new file mode 100644 index 0000000..70f5dcb --- /dev/null +++ b/src/test/java/org/htmlunit/cssparser/parser/validator/SemanticValidationIntegrationTest.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2019-2024 Ronald Brill. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.htmlunit.cssparser.parser.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +import org.htmlunit.cssparser.parser.CSSErrorHandler; +import org.htmlunit.cssparser.parser.CSSException; +import org.htmlunit.cssparser.parser.CSSOMParser; +import org.htmlunit.cssparser.parser.CSSParseException; +import org.htmlunit.cssparser.parser.InputSource; +import org.junit.jupiter.api.Test; + +/** + * Integration test for semantic validation. + * + * @author Ronald Brill + */ +public class SemanticValidationIntegrationTest { + + /** + * Simple error collector for testing. + */ + private static final class ErrorCollector implements CSSErrorHandler { + private final List errors_ = new ArrayList<>(); + private final List warnings_ = new ArrayList<>(); + + @Override + public void warning(final CSSParseException exception) throws CSSException { + warnings_.add(exception.getMessage()); + } + + @Override + public void error(final CSSParseException exception) throws CSSException { + errors_.add(exception.getMessage()); + } + + @Override + public void fatalError(final CSSParseException exception) throws CSSException { + errors_.add(exception.getMessage()); + } + + public int getErrorCount() { + return errors_.size(); + } + + public int getWarningCount() { + return warnings_.size(); + } + + public String getFirstError() { + return errors_.isEmpty() ? null : errors_.get(0); + } + + public List getErrors() { + return errors_; + } + + public List getWarnings() { + return warnings_; + } + } + + @Test + public void testIntegrationWithParserDisabled() throws Exception { + final String css = ".test { colr: red; widht: 100px; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(false); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // With validation disabled, no errors should be reported + assertEquals(0, errorHandler.getErrorCount()); + } + + @Test + public void testIntegrationWithParserEnabled() throws Exception { + final String css = ".test { colr: red; widht: 100px; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // With validation enabled, errors should be reported + assertTrue(errorHandler.getErrorCount() >= 2); + + boolean foundColrError = false; + boolean foundWidhtError = false; + for (final String error : errorHandler.getErrors()) { + if (error.contains("colr")) { + foundColrError = true; + } + if (error.contains("widht")) { + foundWidhtError = true; + } + } + + assertTrue(foundColrError, "Should have detected 'colr' typo"); + assertTrue(foundWidhtError, "Should have detected 'widht' typo"); + } + + @Test + public void testValidCSSProducesNoErrors() throws Exception { + final String css = ".test { color: red; width: 100px; margin: 10px; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // Valid CSS should produce no validation errors + assertEquals(0, errorHandler.getErrorCount()); + } + + @Test + public void testCustomPropertiesNoErrors() throws Exception { + final String css = ".test { --my-color: red; --my-width: 100px; color: var(--my-color); }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // Custom properties should be valid + assertEquals(0, errorHandler.getErrorCount()); + } + + @Test + public void testVendorPrefixesNoErrors() throws Exception { + final String css = ".test { -webkit-transform: rotate(45deg); -moz-border-radius: 5px; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // Vendor prefixes should be valid + assertEquals(0, errorHandler.getErrorCount()); + } + + @Test + public void testGlobalKeywordsNoErrors() throws Exception { + final String css = ".test { color: inherit; width: initial; margin: unset; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // Global keywords should be valid for any property + assertEquals(0, errorHandler.getErrorCount()); + } + + @Test + public void testTypoSuggestionInErrorMessage() throws Exception { + final String css = ".test { colr: red; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + assertTrue(errorHandler.getErrorCount() > 0); + final String error = errorHandler.getFirstError(); + assertTrue(error.contains("colr")); + assertTrue(error.contains("color"), "Error should suggest 'color' as correction"); + } + + @Test + public void testMultipleProperties() throws Exception { + final String css = ".test { color: red; colr: blue; width: 100px; widht: 50px; }"; + + final CSSOMParser parser = new CSSOMParser(); + parser.setSemanticValidationEnabled(true); + final ErrorCollector errorHandler = new ErrorCollector(); + parser.setErrorHandler(errorHandler); + + parser.parseStyleSheet(new InputSource(new StringReader(css)), null); + + // Should catch both typos + assertTrue(errorHandler.getErrorCount() >= 2); + } +}