diff --git a/Cargo.lock b/Cargo.lock index 5d384f9a1c943..7d409579b4943 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1710,7 +1710,6 @@ dependencies = [ "oxc_parser", "oxc_semantic", "oxc_span", - "rayon", "ropey", "serde", "serde_json", @@ -1749,6 +1748,7 @@ dependencies = [ "regex", "rust-lapper", "rustc-hash", + "serde", "serde_json", ] diff --git a/crates/oxc_language_server/Cargo.toml b/crates/oxc_language_server/Cargo.toml index 87c08ded5698d..05653454b7838 100644 --- a/crates/oxc_language_server/Cargo.toml +++ b/crates/oxc_language_server/Cargo.toml @@ -32,7 +32,6 @@ env_logger = { workspace = true } futures = { workspace = true } ignore = { workspace = true, features = ["simd-accel"] } miette = { workspace = true, features = ["fancy-no-backtrace"] } -rayon = { workspace = true } ropey = { workspace = true } tokio = { workspace = true, features = ["full"] } tower-lsp = { workspace = true, features = ["proposed"] } diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index bba085acd4daa..9dce3112a9c0d 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -34,6 +34,7 @@ oxc_resolver = { version = "1.1.0" } rayon = { workspace = true } lazy_static = { workspace = true } # used in oxc_macros serde_json = { workspace = true } +serde = { workspace = true } regex = { workspace = true } rustc-hash = { workspace = true } phf = { workspace = true, features = ["macros"] } diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 6b4f8336a2524..675a76053bbd2 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -152,6 +152,7 @@ mod react { pub mod no_render_return_value; pub mod no_string_refs; pub mod no_unescaped_entities; + pub mod no_unknown_property; pub mod react_in_jsx_scope; } @@ -469,6 +470,7 @@ oxc_macros::declare_all_lint_rules! { react::no_string_refs, react::no_unescaped_entities, react::no_is_mounted, + react::no_unknown_property, import::default, import::named, import::no_cycle, diff --git a/crates/oxc_linter/src/rules/react/no_unknown_property.rs b/crates/oxc_linter/src/rules/react/no_unknown_property.rs new file mode 100644 index 0000000000000..3e25053b92b03 --- /dev/null +++ b/crates/oxc_linter/src/rules/react/no_unknown_property.rs @@ -0,0 +1,734 @@ +use itertools::Itertools; +use once_cell::sync::Lazy; +use oxc_ast::{ + ast::{JSXAttributeItem, JSXAttributeName}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::{GetSpan, Span}; +use phf::{phf_map, phf_set, Map, Set}; +use regex::Regex; +use serde::Deserialize; +use std::collections::hash_map::HashMap; +use std::collections::hash_set::HashSet; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{get_element_type, get_prop_name}, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +enum NoUnknownPropertyDiagnostic { + #[error("eslint-plugin-react(no-unknown-property): Invalid property found")] + #[diagnostic(severity(warning), help("Property '{1}' is only allowed on: {2}"))] + InvalidPropOnTag(#[label] Span, String, String), + #[error("eslint-plugin-react(no-unknown-property): React does not recognize data-* props with uppercase characters on a DOM element")] + #[diagnostic(severity(warning), help("Use '{1}' instead"))] + DataLowercaseRequired(#[label] Span, String), + #[error("eslint-plugin-react(no-unknown-property): Unknown property found")] + #[diagnostic(severity(warning), help("Use '{1}' instead"))] + UnknownPropWithStandardName(#[label] Span, String), + #[error("eslint-plugin-react(no-unknown-property): Unknown property found")] + #[diagnostic(severity(warning), help("Remove unknown property"))] + UnknownProp(#[label] Span), +} + +#[derive(Debug, Default, Clone)] +pub struct NoUnknownProperty(Box); + +#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoUnknownPropertyConfig { + #[serde(default)] + ignore: HashSet, + #[serde(default)] + require_data_lowercase: bool, +} + +declare_oxc_lint!( + /// ### What it does + /// Disallow usage of unknown DOM property. + /// + /// ### Why is this bad? + /// You can use unknown property name that has no effect. + /// + /// ### Example + /// ```jsx + /// // Unknown properties + /// const Hello =
Hello World
; + /// const Alphabet =
Alphabet
; + /// + /// // Invalid aria-* attribute + /// const IconButton =
; + /// ``` + NoUnknownProperty, + correctness +); +const ATTRIBUTE_TAGS_MAP: Map<&'static str, Set<&'static str>> = phf_map! { + "abbr" => phf_set! {"th", "td"}, + "charset" => phf_set! {"meta"}, + "checked" => phf_set! {"input"}, + // image is required for SVG support, all other tags are HTML. + "crossOrigin" => phf_set! {"script", "img", "video", "audio", "link", "image"}, + "displaystyle" => phf_set! {"math"}, + // https://html.spec.whatwg.org/multipage/links.html#downloading-resources + "download" => phf_set! {"a", "area"}, + "fill" => phf_set! { + // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill + // Fill color + "altGlyph", + "circle", + "ellipse", + "g", + "line", + "marker", + "mask", + "path", + "polygon", + "polyline", + "rect", + "svg", + "symbol", + "text", + "textPath", + "tref", + "tspan", + "use", + // Animation final state + "animate", + "animateColor", + "animateMotion", + "animateTransform", + "set", + }, + "focusable" => phf_set! {"svg"}, + "imageSizes" => phf_set! {"link"}, + "imageSrcSet" => phf_set! {"link"}, + "property" => phf_set! {"meta"}, + "viewBox" => phf_set! {"marker", "pattern", "svg", "symbol", "view"}, + "as" => phf_set! {"link"}, + "align" => phf_set! { + "applet", "caption", "col", "colgroup", "hr", "iframe", "img", "table", "tbody", "td", + "tfoot", "th", "thead", "tr", + }, + // deprecated, but known + "valign" => phf_set! {"tr", "td", "th", "thead", "tbody", "tfoot", "colgroup", "col"}, // deprecated, but known + "noModule" => phf_set! {"script"}, + // Media events allowed only on audio and video tags, see https://github.com/facebook/react/blob/256aefbea1449869620fb26f6ec695536ab453f5/CHANGELOG.md#notable-enhancements + "onAbort" => phf_set! {"audio", "video"}, + "onCancel" => phf_set! {"dialog"}, + "onCanPlay" => phf_set! {"audio", "video"}, + "onCanPlayThrough" => phf_set! {"audio", "video"}, + "onClose" => phf_set! {"dialog"}, + "onDurationChange" => phf_set! {"audio", "video"}, + "onEmptied" => phf_set! {"audio", "video"}, + "onEncrypted" => phf_set! {"audio", "video"}, + "onEnded" => phf_set! {"audio", "video"}, + "onError" => phf_set! {"audio", "video", "img", "link", "source", "script", "picture", "iframe"}, + "onLoad" => phf_set! {"script", "img", "link", "picture", "iframe", "object", "source"}, + "onLoadedData" => phf_set! {"audio", "video"}, + "onLoadedMetadata" => phf_set! {"audio", "video"}, + "onLoadStart" => phf_set! {"audio", "video"}, + "onPause" => phf_set! {"audio", "video"}, + "onPlay" => phf_set! {"audio", "video"}, + "onPlaying" => phf_set! {"audio", "video"}, + "onProgress" => phf_set! {"audio", "video"}, + "onRateChange" => phf_set! {"audio", "video"}, + "onResize" => phf_set! {"audio", "video"}, + "onSeeked" => phf_set! {"audio", "video"}, + "onSeeking" => phf_set! {"audio", "video"}, + "onStalled" => phf_set! {"audio", "video"}, + "onSuspend" => phf_set! {"audio", "video"}, + "onTimeUpdate" => phf_set! {"audio", "video"}, + "onVolumeChange" => phf_set! {"audio", "video"}, + "onWaiting" => phf_set! {"audio", "video"}, + "autoPictureInPicture" => phf_set! {"video"}, + "controls" => phf_set! {"audio", "video"}, + "controlsList" => phf_set! {"audio", "video"}, + "disablePictureInPicture" => phf_set! {"video"}, + "disableRemotePlayback" => phf_set! {"audio", "video"}, + "loop" => phf_set! {"audio", "video"}, + "muted" => phf_set! {"audio", "video"}, + "playsInline" => phf_set! {"video"}, + "allowFullScreen" => phf_set! {"iframe", "video"}, + "webkitAllowFullScreen" => phf_set! {"iframe", "video"}, + "mozAllowFullScreen" => phf_set! {"iframe", "video"}, + "poster" => phf_set! {"video"}, + "preload" => phf_set! {"audio", "video"}, + "scrolling" => phf_set! {"iframe"}, + "returnValue" => phf_set! {"dialog"}, + "webkitDirectory" => phf_set! {"input"}, +}; + +const DOM_PROPERTIES_NAMES: Set<&'static str> = phf_set! { + // Global attributes - can be used on any HTML/DOM element + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes + "dir", "draggable", "hidden", "id", "lang", "nonce", "part", "slot", "style", "title", "translate", "inert", + // Element specific attributes + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes (includes global attributes too) + // To be considered if these should be added also to ATTRIBUTE_TAGS_MAP + "accept", "action", "allow", "alt", "as", "async", "buffered", "capture", "challenge", "cite", "code", "cols", + "content", "coords", "csp", "data", "decoding", "default", "defer", "disabled", "form", + "headers", "height", "high", "href", "icon", "importance", "integrity", "kind", "label", + "language", "loading", "list", "loop", "low", "manifest", "max", "media", "method", "min", "multiple", "muted", + "name", "open", "optimum", "pattern", "ping", "placeholder", "poster", "preload", "profile", + "rel", "required", "reversed", "role", "rows", "sandbox", "scope", "seamless", "selected", "shape", "size", "sizes", + "span", "src", "start", "step", "summary", "target", "type", "value", "width", "wmode", "wrap", + // SVG attributes + // See https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute + "accumulate", "additive", "alphabetic", "amplitude", "ascent", "azimuth", "bbox", "begin", + "bias", "by", "clip", "color", "cursor", "cx", "cy", "d", "decelerate", "descent", "direction", + "display", "divisor", "dur", "dx", "dy", "elevation", "end", "exponent", "fill", "filter", + "format", "from", "fr", "fx", "fy", "g1", "g2", "hanging", "hreflang", "ideographic", + "in", "in2", "intercept", "k", "k1", "k2", "k3", "k4", "kerning", "local", "mask", "mode", + "offset", "opacity", "operator", "order", "orient", "orientation", "origin", "overflow", "path", + "points", "r", "radius", "restart", "result", "rotate", "rx", "ry", "scale", + "seed", "slope", "spacing", "speed", "stemh", "stemv", "string", "stroke", "to", "transform", + "u1", "u2", "unicode", "values", "version", "visibility", "widths", "x", "x1", "x2", "xmlns", + "y", "y1", "y2", "z", + // OpenGraph meta tag attributes + "property", + // React specific attributes + "ref", "key", "children", + // Non-standard + "results", "security", + // Video specific + "controls", + // TWO WORD DOM_PROPERTIES_NAMES + + // Global attributes - can be used on any HTML/DOM element + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes + "accessKey", "autoCapitalize", "autoFocus", "contentEditable", "enterKeyHint", "exportParts", + "inputMode", "itemID", "itemRef", "itemProp", "itemScope", "itemType", "spellCheck", "tabIndex", + // Element specific attributes + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes (includes global attributes too) + // To be considered if these should be added also to ATTRIBUTE_TAGS_MAP + "acceptCharset", "autoComplete", "autoPlay", "border", "cellPadding", "cellSpacing", "classID", "codeBase", + "colSpan", "contextMenu", "dateTime", "encType", "formAction", "formEncType", "formMethod", "formNoValidate", "formTarget", + "frameBorder", "hrefLang", "httpEquiv", "imageSizes", "imageSrcSet", "isMap", "keyParams", "keyType", "marginHeight", "marginWidth", + "maxLength", "mediaGroup", "minLength", "noValidate", "onAnimationEnd", "onAnimationIteration", "onAnimationStart", + "onBlur", "onChange", "onClick", "onContextMenu", "onCopy", "onCompositionEnd", "onCompositionStart", + "onCompositionUpdate", "onCut", "onDoubleClick", "onDrag", "onDragEnd", "onDragEnter", "onDragExit", "onDragLeave", + "onError", "onFocus", "onInput", "onKeyDown", "onKeyPress", "onKeyUp", "onLoad", "onWheel", "onDragOver", + "onDragStart", "onDrop", "onMouseDown", "onMouseEnter", "onMouseLeave", "onMouseMove", "onMouseOut", "onMouseOver", + "onMouseUp", "onPaste", "onScroll", "onSelect", "onSubmit", "onToggle", "onTransitionEnd", "radioGroup", "readOnly", "referrerPolicy", + "rowSpan", "srcDoc", "srcLang", "srcSet", "useMap", + // SVG attributes + // See https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute + "crossOrigin", "accentHeight", "alignmentBaseline", "arabicForm", "attributeName", + "attributeType", "baseFrequency", "baselineShift", "baseProfile", "calcMode", "capHeight", + "clipPathUnits", "clipPath", "clipRule", "colorInterpolation", "colorInterpolationFilters", + "colorProfile", "colorRendering", "contentScriptType", "contentStyleType", "diffuseConstant", + "dominantBaseline", "edgeMode", "enableBackground", "fillOpacity", "fillRule", "filterRes", + "filterUnits", "floodColor", "floodOpacity", "fontFamily", "fontSize", "fontSizeAdjust", + "fontStretch", "fontStyle", "fontVariant", "fontWeight", "glyphName", + "glyphOrientationHorizontal", "glyphOrientationVertical", "glyphRef", "gradientTransform", + "gradientUnits", "horizAdvX", "horizOriginX", "imageRendering", "kernelMatrix", + "kernelUnitLength", "keyPoints", "keySplines", "keyTimes", "lengthAdjust", "letterSpacing", + "lightingColor", "limitingConeAngle", "markerEnd", "markerMid", "markerStart", "markerHeight", + "markerUnits", "markerWidth", "maskContentUnits", "maskUnits", "mathematical", "numOctaves", + "overlinePosition", "overlineThickness", "panose1", "paintOrder", "pathLength", + "patternContentUnits", "patternTransform", "patternUnits", "pointerEvents", "pointsAtX", + "pointsAtY", "pointsAtZ", "preserveAlpha", "preserveAspectRatio", "primitiveUnits", + "refX", "refY", "rendering-intent", "repeatCount", "repeatDur", + "requiredExtensions", "requiredFeatures", "shapeRendering", "specularConstant", + "specularExponent", "spreadMethod", "startOffset", "stdDeviation", "stitchTiles", "stopColor", + "stopOpacity", "strikethroughPosition", "strikethroughThickness", "strokeDasharray", + "strokeDashoffset", "strokeLinecap", "strokeLinejoin", "strokeMiterlimit", "strokeOpacity", + "strokeWidth", "surfaceScale", "systemLanguage", "tableValues", "targetX", "targetY", + "textAnchor", "textDecoration", "textRendering", "textLength", "transformOrigin", + "underlinePosition", "underlineThickness", "unicodeBidi", "unicodeRange", "unitsPerEm", + "vAlphabetic", "vHanging", "vIdeographic", "vMathematical", "vectorEffect", "vertAdvY", + "vertOriginX", "vertOriginY", "viewBox", "viewTarget", "wordSpacing", "writingMode", "xHeight", + "xChannelSelector", "xlinkActuate", "xlinkArcrole", "xlinkHref", "xlinkRole", "xlinkShow", + "xlinkTitle", "xlinkType", "xmlBase", "xmlLang", "xmlnsXlink", "xmlSpace", "yChannelSelector", + "zoomAndPan", + // Safari/Apple specific, no listing available + "autoCorrect", // https://stackoverflow.com/questions/47985384/html-autocorrect-for-text-input-is-not-working + "autoSave", // https://stackoverflow.com/questions/25456396/what-is-autosave-attribute-supposed-to-do-how-do-i-use-it + // React specific attributes https://reactjs.org/docs/dom-elements.html#differences-in-attributes + "className", "dangerouslySetInnerHTML", "defaultValue", "defaultChecked", "htmlFor", + // Events" capture events + "onBeforeInput", + "onInvalid", "onReset", "onTouchCancel", "onTouchEnd", "onTouchMove", "onTouchStart", "suppressContentEditableWarning", "suppressHydrationWarning", + "onAbort", "onCanPlay", "onCanPlayThrough", "onDurationChange", "onEmptied", "onEncrypted", "onEnded", + "onLoadedData", "onLoadedMetadata", "onLoadStart", "onPause", "onPlay", "onPlaying", "onProgress", "onRateChange", "onResize", + "onSeeked", "onSeeking", "onStalled", "onSuspend", "onTimeUpdate", "onVolumeChange", "onWaiting", + "onCopyCapture", "onCutCapture", "onPasteCapture", "onCompositionEndCapture", "onCompositionStartCapture", "onCompositionUpdateCapture", + "onFocusCapture", "onBlurCapture", "onChangeCapture", "onBeforeInputCapture", "onInputCapture", "onResetCapture", "onSubmitCapture", + "onInvalidCapture", "onLoadCapture", "onErrorCapture", "onKeyDownCapture", "onKeyPressCapture", "onKeyUpCapture", + "onAbortCapture", "onCanPlayCapture", "onCanPlayThroughCapture", "onDurationChangeCapture", "onEmptiedCapture", "onEncryptedCapture", + "onEndedCapture", "onLoadedDataCapture", "onLoadedMetadataCapture", "onLoadStartCapture", "onPauseCapture", "onPlayCapture", + "onPlayingCapture", "onProgressCapture", "onRateChangeCapture", "onSeekedCapture", "onSeekingCapture", "onStalledCapture", "onSuspendCapture", + "onTimeUpdateCapture", "onVolumeChangeCapture", "onWaitingCapture", "onSelectCapture", "onTouchCancelCapture", "onTouchEndCapture", + "onTouchMoveCapture", "onTouchStartCapture", "onScrollCapture", "onWheelCapture", "onAnimationEndCapture", + "onAnimationStartCapture", "onTransitionEndCapture", + "onAuxClick", "onAuxClickCapture", "onClickCapture", "onContextMenuCapture", "onDoubleClickCapture", + "onDragCapture", "onDragEndCapture", "onDragEnterCapture", "onDragExitCapture", "onDragLeaveCapture", + "onDragOverCapture", "onDragStartCapture", "onDropCapture", "onMouseDownCapture", + "onMouseMoveCapture", "onMouseOutCapture", "onMouseOverCapture", "onMouseUpCapture", + // Video specific + "autoPictureInPicture", "controlsList", "disablePictureInPicture", "disableRemotePlayback", + + // React on props + "onGotPointerCaptureCapture", + "onLostPointerCapture", + "onLostPointerCaptureCapture", + "onPointerCancel", + "onPointerCancelCapture", + "onPointerDown", + "onPointerDownCapture", + "onPointerEnter", + "onPointerEnterCapture", + "onPointerLeave", + "onPointerLeaveCapture", + "onPointerMove", + "onPointerMoveCapture", + "onPointerOut", + "onPointerOutCapture", + "onPointerOver", + "onPointerOverCapture", + "onPointerUp", + "onPointerUpCapture", +}; + +const ARIA_PROPERTIES: Set<&'static str> = phf_set! { + // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes + // Global attributes + "aria-atomic", "aria-braillelabel", "aria-brailleroledescription", "aria-busy", "aria-controls", "aria-current", + "aria-describedby", "aria-description", "aria-details", + "aria-disabled", "aria-dropeffect", "aria-errormessage", "aria-flowto", "aria-grabbed", "aria-haspopup", + "aria-hidden", "aria-invalid", "aria-keyshortcuts", "aria-label", "aria-labelledby", "aria-live", + "aria-owns", "aria-relevant", "aria-roledescription", + // Widget attributes + "aria-autocomplete", "aria-checked", "aria-expanded", "aria-level", "aria-modal", "aria-multiline", "aria-multiselectable", + "aria-orientation", "aria-placeholder", "aria-pressed", "aria-readonly", "aria-required", "aria-selected", + "aria-sort", "aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-valuetext", + // Relationship attributes + "aria-activedescendant", "aria-colcount", "aria-colindex", "aria-colindextext", "aria-colspan", + "aria-posinset", "aria-rowcount", "aria-rowindex", "aria-rowindextext", "aria-rowspan", "aria-setsize", +}; + +const DOM_ATTRIBUTES_TO_CAMEL: Map<&'static str, &'static str> = phf_map! { + "accept-charset" => "acceptCharset", + "class" => "className", + "http-equiv" => "httpEquiv", + "crossorigin" => "crossOrigin", + "for" => "htmlFor", + "nomodule" => "noModule", + // svg + "accent-height" => "accentHeight", + "alignment-baseline" => "alignmentBaseline", + "arabic-form" => "arabicForm", + "baseline-shift" => "baselineShift", + "cap-height" => "capHeight", + "clip-path" => "clipPath", + "clip-rule" => "clipRule", + "color-interpolation" => "colorInterpolation", + "color-interpolation-filters" => "colorInterpolationFilters", + "color-profile" => "colorProfile", + "color-rendering" => "colorRendering", + "dominant-baseline" => "dominantBaseline", + "enable-background" => "enableBackground", + "fill-opacity" => "fillOpacity", + "fill-rule" => "fillRule", + "flood-color" => "floodColor", + "flood-opacity" => "floodOpacity", + "font-family" => "fontFamily", + "font-size" => "fontSize", + "font-size-adjust" => "fontSizeAdjust", + "font-stretch" => "fontStretch", + "font-style" => "fontStyle", + "font-variant" => "fontVariant", + "font-weight" => "fontWeight", + "glyph-name" => "glyphName", + "glyph-orientation-horizontal" => "glyphOrientationHorizontal", + "glyph-orientation-vertical" => "glyphOrientationVertical", + "horiz-adv-x" => "horizAdvX", + "horiz-origin-x" => "horizOriginX", + "image-rendering" => "imageRendering", + "letter-spacing" => "letterSpacing", + "lighting-color" => "lightingColor", + "marker-end" => "markerEnd", + "marker-mid" => "markerMid", + "marker-start" => "markerStart", + "overline-position" => "overlinePosition", + "overline-thickness" => "overlineThickness", + "paint-order" => "paintOrder", + "panose-1" => "panose1", + "pointer-events" => "pointerEvents", + "rendering-intent" => "renderingIntent", + "shape-rendering" => "shapeRendering", + "stop-color" => "stopColor", + "stop-opacity" => "stopOpacity", + "strikethrough-position" => "strikethroughPosition", + "strikethrough-thickness" => "strikethroughThickness", + "stroke-dasharray" => "strokeDasharray", + "stroke-dashoffset" => "strokeDashoffset", + "stroke-linecap" => "strokeLinecap", + "stroke-linejoin" => "strokeLinejoin", + "stroke-miterlimit" => "strokeMiterlimit", + "stroke-opacity" => "strokeOpacity", + "stroke-width" => "strokeWidth", + "text-anchor" => "textAnchor", + "text-decoration" => "textDecoration", + "text-rendering" => "textRendering", + "underline-position" => "underlinePosition", + "underline-thickness" => "underlineThickness", + "unicode-bidi" => "unicodeBidi", + "unicode-range" => "unicodeRange", + "units-per-em" => "unitsPerEm", + "v-alphabetic" => "vAlphabetic", + "v-hanging" => "vHanging", + "v-ideographic" => "vIdeographic", + "v-mathematical" => "vMathematical", + "vector-effect" => "vectorEffect", + "vert-adv-y" => "vertAdvY", + "vert-origin-x" => "vertOriginX", + "vert-origin-y" => "vertOriginY", + "word-spacing" => "wordSpacing", + "writing-mode" => "writingMode", + "x-height" => "xHeight", + "xlink:actuate" => "xlinkActuate", + "xlink:arcrole" => "xlinkArcrole", + "xlink:href" => "xlinkHref", + "xlink:role" => "xlinkRole", + "xlink:show" => "xlinkShow", + "xlink:title" => "xlinkTitle", + "xlink:type" => "xlinkType", + "xml:base" => "xmlBase", + "xml:lang" => "xmlLang", + "xml:space" => "xmlSpace", +}; + +const DOM_PROPERTIES_IGNORE_CASE: [&str; 5] = [ + "charset", + "allowFullScreen", + "webkitAllowFullScreen", + "mozAllowFullScreen", + "webkitDirectory", +]; + +static DOM_PROPERTIES_LOWER_MAP: Lazy> = Lazy::new(|| { + DOM_PROPERTIES_NAMES.iter().map(|it| (it.to_lowercase(), *it)).collect::>() +}); + +/// +/// Checks if an attribute name is a valid `data-*` attribute: +/// if the name starts with "data-" and has alphanumeric words (browsers require lowercase, but React and TS lowercase them), +/// not start with any casing of "xml", and separated by hyphens (-) (which is also called "kebab case" or "dash case"), +/// then the attribute is a valid data attribute. +/// +fn is_valid_data_attr(name: &str) -> bool { + static DATA_ATTR_REGEX: Lazy = Lazy::new(|| Regex::new(r"^data(-?[^:]*)$").unwrap()); + + !name.to_lowercase().starts_with("data-xml") && DATA_ATTR_REGEX.is_match(name) +} + +fn normalize_attribute_case(name: &str) -> &str { + DOM_PROPERTIES_IGNORE_CASE + .iter() + .find(|camel_name| camel_name.to_lowercase() == name.to_lowercase()) + .unwrap_or(&name) +} +fn has_uppercase(name: &str) -> bool { + name.contains(char::is_uppercase) +} + +impl Rule for NoUnknownProperty { + fn from_configuration(value: serde_json::Value) -> Self { + value + .as_array() + .and_then(|arr| arr.first()) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .map_or_else(Self::default, |value| Self(Box::new(value))) + } + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + static HTML_TAG_CONVENTION: Lazy = Lazy::new(|| Regex::new("^[a-z][^-]*$").unwrap()); + + let AstKind::JSXOpeningElement(el) = &node.kind() else { + return; + }; + let Some(el_type) = get_element_type(ctx, el) else { + return; + }; + // fbt/fbs nodes are bonkers, let's not go there + if !el_type.starts_with(char::is_lowercase) || el_type == "fbt" || el_type == "fbs" { + return; + } + + let is_valid_html_tag = HTML_TAG_CONVENTION.is_match(el_type.as_str()) + && el.attributes.iter().all(|attr| { + let JSXAttributeItem::Attribute(jsx_attr) = attr else { + return true; + }; + let JSXAttributeName::Identifier(ident) = &jsx_attr.name else { + return true; + }; + ident.name.as_str() != "is" + }); + + el.attributes + .iter() + .filter_map(|attr| match &attr { + JSXAttributeItem::Attribute(regular) => Some(&**regular), + JSXAttributeItem::SpreadAttribute(_) => None, + }) + .for_each(|attr| { + let span = attr.name.span(); + let actual_name = get_prop_name(&attr.name); + if self.0.ignore.contains(&(actual_name)) { + return; + }; + if is_valid_data_attr(actual_name.as_str()) { + if self.0.require_data_lowercase && has_uppercase(actual_name.as_str()) { + ctx.diagnostic(NoUnknownPropertyDiagnostic::DataLowercaseRequired( + span, + actual_name.to_lowercase(), + )); + } + return; + }; + if ARIA_PROPERTIES.contains(actual_name.as_str()) || !is_valid_html_tag { + return; + }; + let name = normalize_attribute_case(actual_name.as_str()); + if let Some(tags) = ATTRIBUTE_TAGS_MAP.get(name) { + if !tags.contains(el_type.as_str()) { + ctx.diagnostic(NoUnknownPropertyDiagnostic::InvalidPropOnTag( + span, + actual_name.to_string(), + tags.iter().join(", "), + )); + } + return; + } + + if DOM_PROPERTIES_NAMES.contains(name) { + return; + } + + DOM_PROPERTIES_LOWER_MAP + .get(&name.to_lowercase()) + .or_else(|| DOM_ATTRIBUTES_TO_CAMEL.get(name)) + .map_or_else( + || { + ctx.diagnostic(NoUnknownPropertyDiagnostic::UnknownProp(span)); + }, + |prop| { + ctx.diagnostic( + NoUnknownPropertyDiagnostic::UnknownPropWithStandardName( + span, + (*prop).to_string(), + ), + ); + }, + ); + }); + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#";"#, None), + (r#"
;"#, None), + (r"
;", None), + (r#"Read more"#, None), + (r#""#, None), + (r#"A cat sleeping on a keyboard"#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#""#, None), + (r#"
"#, None), + (r#"