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);
+ }
+}