Add example rule to report unused CSS classes

This commit is contained in:
Jeroen Engels 2024-05-07 00:55:52 +02:00
parent bf40c54e2a
commit 14132fbf29
3 changed files with 720 additions and 1 deletions

View File

@ -1903,7 +1903,153 @@ doesn't analyze by default.
The visitor function will be called with all the files matching the file patterns. The visitor function will be called with all the files matching the file patterns.
REPLACEME Make a rule that reports unused classes in CSS files. The following example rule reads a project's `.css` files to extract all the mentioned CSS classes,
then finds calls to `Html.Attributes.class` in the Elm code (such as `Html.Attributes.class "big-red-button"`)
and reports errors when the classes given as argument are unknown.
import Dict exposing (Dict)
import Elm.Syntax.Expression as Expression exposing (Expression)
import Elm.Syntax.Node as Node exposing (Node)
import Elm.Syntax.Range exposing (Range)
import Regex exposing (Regex)
import Review.FilePattern as FilePattern
import Review.Rule as Rule exposing (Rule)
import Set exposing (Set)
rule : Rule
rule =
Rule.newProjectRuleSchema "NoUnusedCssClasses" initialProjectContext
|> Rule.withExtraFilesProjectVisitor cssFilesVisitor
[ FilePattern.include "**/*.css" ]
|> Rule.withModuleVisitor moduleVisitor
|> Rule.withModuleContextUsingContextCreator
{ fromProjectToModule = fromProjectToModule
, fromModuleToProject = fromModuleToProject
, foldProjectContexts = foldProjectContexts
}
|> Rule.withFinalProjectEvaluation finalEvaluation
|> Rule.fromProjectRuleSchema
moduleVisitor : Rule.ModuleRuleSchema {} ModuleContext -> Rule.ModuleRuleSchema { hasAtLeastOneVisitor : () } ModuleContext
moduleVisitor schema =
schema
|> Rule.withExpressionEnterVisitor expressionVisitor
type alias ProjectContext =
{ cssFiles :
Dict
String
{ fileKey : Rule.ExtraFileKey
, classes : Set String
}
, usedCssClasses : Set String
}
type alias ModuleContext =
{ usedCssClasses : Set String
}
initialProjectContext : ProjectContext
initialProjectContext =
{ cssFiles = Dict.empty
, usedCssClasses = Set.empty
}
fromProjectToModule : Rule.ContextCreator ProjectContext ModuleContext
fromProjectToModule =
Rule.initContextCreator (\_ -> { usedCssClasses = Set.empty })
fromModuleToProject : Rule.ContextCreator ModuleContext ProjectContext
fromModuleToProject =
Rule.initContextCreator
(\{ usedCssClasses } ->
{ cssFiles = Dict.empty
, usedCssClasses = usedCssClasses
}
)
foldProjectContexts : ProjectContext -> ProjectContext -> ProjectContext
foldProjectContexts newContext previousContext =
{ cssFiles = previousContext.cssFiles
, usedCssClasses = Set.union newContext.usedCssClasses previousContext.usedCssClasses
}
cssClassRegex : Regex
cssClassRegex =
Regex.fromString "\\.([\\w-_]+)"
|> Maybe.withDefault Regex.never
cssFilesVisitor : Dict String { fileKey : Rule.ExtraFileKey, content : String } -> ProjectContext -> ( List (Rule.Error { useErrorForModule : () }), ProjectContext )
cssFilesVisitor files context =
( []
, { context
| cssFiles =
Dict.map
(\_ { fileKey, content } ->
{ fileKey = fileKey
, classes =
Regex.find cssClassRegex content
|> List.map (\m -> String.dropLeft 1 m.match)
|> Set.fromList
}
)
files
}
)
expressionVisitor : Node Expression -> ModuleContext -> ( List (Rule.Error {}), ModuleContext )
expressionVisitor node context =
case Node.value node of
Expression.Application [ function, firstArg ] ->
case Node.value function of
Expression.FunctionOrValue [ "Html", "Attributes" ] "class" ->
case Node.value firstArg of
Expression.Literal stringLiteral ->
let
usedCssClasses : List String
usedCssClasses =
String.split " " stringLiteral
in
( []
, { context | usedCssClasses = List.foldl Set.insert context.usedCssClasses usedCssClasses }
)
_ ->
( [], context )
_ ->
( [], context )
_ ->
( [], context )
finalEvaluation : ProjectContext -> List (Rule.Error { useErrorForModule : () })
finalEvaluation context =
context.cssFiles
|> Dict.toList
|> List.filterMap (\( filePath, file ) -> reportUnusedClasses context.usedCssClasses filePath file)
reportUnusedClasses : Set String -> String -> { a | fileKey : Rule.ExtraFileKey, classes : Set String } -> Maybe (Rule.Error { useErrorForModule : () })
reportUnusedClasses usedCssClasses filePath { fileKey, classes } =
let
unusedClasses : Set String
unusedClasses =
Set.diff classes usedCssClasses
in
if Set.isEmpty unusedClasses then
Nothing
else
Just
(Rule.errorForExtraFile fileKey
{ message = "Found unused CSS classes in " ++ filePath
, details =
[ "This file declared the usage of some CSS classes for which I could not any usage in the Elm codebase. Please check that no typo was made in the name of the classes, and remove them if they still seem unused."
, "Here are the classes that seem unused: " ++ String.join " " (Set.toList unusedClasses)
]
}
{ start = { row = 1, column = 1 }, end = { row = 2, column = 1 } }
)
-} -}
withExtraFilesProjectVisitor : withExtraFilesProjectVisitor :

View File

@ -0,0 +1,158 @@
module Css.NoUnusedCssClasses exposing (rule)
import Dict exposing (Dict)
import Elm.Syntax.Expression as Expression exposing (Expression)
import Elm.Syntax.Node as Node exposing (Node)
import Elm.Syntax.Range exposing (Range)
import Regex exposing (Regex)
import Review.FilePattern as FilePattern
import Review.Rule as Rule exposing (Rule)
import Set exposing (Set)
rule : Rule
rule =
Rule.newProjectRuleSchema "NoUnusedCssClasses" initialProjectContext
|> Rule.withExtraFilesProjectVisitor cssFilesVisitor
[ FilePattern.include "**/*.css" ]
|> Rule.withModuleVisitor moduleVisitor
|> Rule.withModuleContextUsingContextCreator
{ fromProjectToModule = fromProjectToModule
, fromModuleToProject = fromModuleToProject
, foldProjectContexts = foldProjectContexts
}
|> Rule.withFinalProjectEvaluation finalEvaluation
|> Rule.fromProjectRuleSchema
moduleVisitor : Rule.ModuleRuleSchema {} ModuleContext -> Rule.ModuleRuleSchema { hasAtLeastOneVisitor : () } ModuleContext
moduleVisitor schema =
schema
|> Rule.withExpressionEnterVisitor expressionVisitor
type alias ProjectContext =
{ cssFiles :
Dict
String
{ fileKey : Rule.ExtraFileKey
, classes : Set String
}
, usedCssClasses : Set String
}
type alias ModuleContext =
{ usedCssClasses : Set String
}
initialProjectContext : ProjectContext
initialProjectContext =
{ cssFiles = Dict.empty
, usedCssClasses = Set.empty
}
fromProjectToModule : Rule.ContextCreator ProjectContext ModuleContext
fromProjectToModule =
Rule.initContextCreator (\_ -> { usedCssClasses = Set.empty })
fromModuleToProject : Rule.ContextCreator ModuleContext ProjectContext
fromModuleToProject =
Rule.initContextCreator
(\{ usedCssClasses } ->
{ cssFiles = Dict.empty
, usedCssClasses = usedCssClasses
}
)
foldProjectContexts : ProjectContext -> ProjectContext -> ProjectContext
foldProjectContexts newContext previousContext =
{ cssFiles = previousContext.cssFiles
, usedCssClasses = Set.union newContext.usedCssClasses previousContext.usedCssClasses
}
cssClassRegex : Regex
cssClassRegex =
Regex.fromString "\\.([\\w-_]+)"
|> Maybe.withDefault Regex.never
cssFilesVisitor : Dict String { fileKey : Rule.ExtraFileKey, content : String } -> ProjectContext -> ( List (Rule.Error { useErrorForModule : () }), ProjectContext )
cssFilesVisitor files context =
( []
, { context
| cssFiles =
Dict.map
(\_ { fileKey, content } ->
{ fileKey = fileKey
, classes =
Regex.find cssClassRegex content
|> List.map (\m -> String.dropLeft 1 m.match)
|> Set.fromList
}
)
files
}
)
expressionVisitor : Node Expression -> ModuleContext -> ( List (Rule.Error {}), ModuleContext )
expressionVisitor node context =
case Node.value node of
Expression.Application [ function, firstArg ] ->
case Node.value function of
Expression.FunctionOrValue [ "Html", "Attributes" ] "class" ->
case Node.value firstArg of
Expression.Literal stringLiteral ->
let
usedCssClasses : List String
usedCssClasses =
String.split " " stringLiteral
in
( []
, { context | usedCssClasses = List.foldl Set.insert context.usedCssClasses usedCssClasses }
)
_ ->
( [], context )
_ ->
( [], context )
_ ->
( [], context )
finalEvaluation : ProjectContext -> List (Rule.Error { useErrorForModule : () })
finalEvaluation context =
context.cssFiles
|> Dict.toList
|> List.filterMap (\( filePath, file ) -> reportUnusedClasses context.usedCssClasses filePath file)
reportUnusedClasses : Set String -> String -> { a | fileKey : Rule.ExtraFileKey, classes : Set String } -> Maybe (Rule.Error { useErrorForModule : () })
reportUnusedClasses usedCssClasses filePath { fileKey, classes } =
let
unusedClasses : Set String
unusedClasses =
Set.diff classes usedCssClasses
in
if Set.isEmpty unusedClasses then
Nothing
else
Just
(Rule.errorForExtraFile fileKey
{ message = "Found unused CSS classes in " ++ filePath
, details =
[ "This file declared the usage of some CSS classes for which I could not any usage in the Elm codebase. Please check that no typo was made in the name of the classes, and remove them if they still seem unused."
, "Here are the classes that seem unused: " ++ String.join " " (Set.toList unusedClasses)
]
}
{ start = { row = 1, column = 1 }, end = { row = 2, column = 1 } }
)

View File

@ -0,0 +1,415 @@
module Css.NoUnknownCssClassesTest exposing (all)
import Css.ClassFunction as ClassFunction exposing (CssArgument, fromLiteral)
import Css.NoUnknownCssClasses exposing (addKnownClasses, cssFiles, rule, withCssUsingFunctions)
import Dict
import Elm.Syntax.Expression exposing (Expression)
import Elm.Syntax.Node exposing (Node)
import Review.FilePattern as FilePattern exposing (FilePattern)
import Review.Project as Project exposing (Project)
import Review.Test
import Review.Test.Dependencies
import Test exposing (Test, describe, test)
all : Test
all =
describe "NoUnknownCssClasses"
[ test "should not report an error when strings don't seem to be CSS classes" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [] [ Html.text "ok" ]
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectNoErrors
, test "should report an error when encountering an unknown CSS class through Html.Attributes.class" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ Attr.class "unknown" ] []
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known", "bar", "unknown2" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Unknown CSS class \"unknown\""
, details =
[ "I could not find this class in CSS files. Have you made a typo?"
, "Here are similarly-named classes:\n - unknown2\n - known"
]
, under = "unknown"
}
]
, test "should not report an error when encountering an CSS class specified in the configuration" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ Attr.class "known" ] []
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectNoErrors
, test "should not report an error when the class argument is empty" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ Attr.class "" ] []
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectNoErrors
, test "should not report an error when the class argument is only made out of spaces" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ Attr.class " " ] []
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectNoErrors
, test "should report an error when encountering an unknown CSS class through Html.Attributes.class in <| pipe" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ Attr.class <| "unknown" ] []
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Unknown CSS class \"unknown\""
, details = [ "I could not find this class in CSS files. Have you made a typo?" ]
, under = "unknown"
}
]
, test "should report an error when encountering an unknown CSS class through Html.Attributes.class in |> pipe" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ "unknown" |> Attr.class ] []
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Unknown CSS class \"unknown\""
, details = [ "I could not find this class in CSS files. Have you made a typo?" ]
, under = "unknown"
}
]
, test "should not report an error when encountering CSS classes found in specified files" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Html.span [ "known red-faint under_score" |> Attr.class ] []
"""
|> Review.Test.runWithProjectData projectWithCssClasses (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectNoErrors
, test "should report an error when encountering a non-literal argument for Html.Attributes.class" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.class model.class
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "model.class"
}
]
, test "should report an error when encountering a non-literal argument for Html.Attributes.classList" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.classList model.classList
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "model.classList"
}
]
, test "should report an error when encountering non-literal CSS classes for Html.Attributes.classList" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.classList
[ ( "known", model.a )
, ( variable, model.b )
]
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "variable"
}
]
, test "should report an error when encountering a non-literal argument to Html.Attributes.attribute \"class\"" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.attribute "class" model.class
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "model.class"
}
]
, test "should not report an error when encountering a known class argument to Html.Attributes.attribute \"class\"" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.attribute "class" "known"
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectNoErrors
, test "should not report an error when Html.Attributes.attribute is used with something else than \"class\"" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.attribute "id" model.id
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectNoErrors
, test "should not report an error when Html.Attributes.attribute is used with a non-literal attribute name" <|
\() ->
"""module A exposing (..)
import Html
import Html.Attributes as Attr
view model =
Attr.attribute name model.name
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> rule)
|> Review.Test.expectNoErrors
, test "should not report an error when encountering a literal CSS class with a custom CSS function" <|
\() ->
"""module A exposing (..)
import Class
view model =
Class.fromString "known"
"""
|> Review.Test.run
(cssFiles [ FilePattern.include "*.css" ]
|> addKnownClasses [ "known" ]
|> withCssUsingFunctions [ ( "Class.fromString", classFromAttrFunction ) ]
|> rule
)
|> Review.Test.expectNoErrors
, test "should report an error when encountering a non-literal CSS class with a custom CSS function" <|
\() ->
"""module A exposing (..)
import Class
view model =
Class.fromString model.a
"""
|> Review.Test.run
(cssFiles [ FilePattern.include "*.css" ]
|> addKnownClasses [ "known" ]
|> withCssUsingFunctions [ ( "Class.fromString", classFromAttrFunction ) ]
|> rule
)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "model.a"
}
]
, test "should report an error when encountering a reference to class function outside of a function call" <|
\() ->
"""module A exposing (..)
import Html.Attributes
classListWithoutErrorsBeingReported =
Html.Attributes.classList
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Class using function is used without arguments"
, details = [ "Having the function used without arguments confuses me and will prevent me from figuring out whether the classes passed to this function will be known or unknown. Please pass in all the arguments at the location." ]
, under = "Html.Attributes.classList"
}
]
, test "should report an error when encountering a class function application with less arguments than where their class arguments are" <|
\() ->
"""module A exposing (..)
import Html.Attributes
classFunctionWithoutErrorsBeingReported =
Html.Attributes.attribute "class"
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Class using function is used without all of its CSS class arguments"
, details = [ "Having the function used without all of its arguments confuses me and will prevent me from figuring out whether the classes passed to this function will be known or unknown. Please pass in all the arguments at the location." ]
, under = "Html.Attributes.attribute"
}
]
, test "should report an error when being unable to parse a CSS file" <|
\() ->
"""module A exposing (..)
import Class
view model =
Class.fromString model.a
"""
|> Review.Test.runWithProjectData projectWithUnparsableCssClasses (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrorsForModules
[ ( "some-file.css"
, [ Review.Test.error
{ message = "Unable to parse CSS file `some-file.css`"
, details = [ "Please check that this file is syntactically correct. It is possible that I'm mistaken as my CSS parser is still very naive. Contributions are welcome to solve the issue." ]
, under = "-- First line"
}
]
)
]
, test "should report an error when encountering an if expression as an argument to Html.Attributes.class" <|
\() ->
"""module A exposing (..)
import Html.Attributes as Attr
view model =
Attr.class <| if model.condition then "a" else "b"
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "if model.condition then \"a\" else \"b\""
}
]
, test "should understand if expressions as an argument to Html.Attributes.class" <|
\() ->
"""module A exposing (..)
import Html.Attributes as Attr
view model =
Attr.class <| if model.condition then "known" else nonLiteral
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> withCssUsingFunctions [ ( "Html.Attributes.class", ClassFunction.smartFirstArgumentIsClass ) ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "nonLiteral"
}
]
, test "should understand case expressions as an argument to Html.Attributes.class" <|
\() ->
"""module A exposing (..)
import Html.Attributes as Attr
view model =
Attr.class <|
case model.thing of
A -> "known"
B -> nonLiteral
"""
|> Review.Test.run (cssFiles [ FilePattern.include "*.css" ] |> addKnownClasses [ "known" ] |> withCssUsingFunctions [ ( "Html.Attributes.class", ClassFunction.smartFirstArgumentIsClass ) ] |> rule)
|> Review.Test.expectErrors
[ Review.Test.error
{ message = "Non-literal argument to CSS class function"
, details = [ "The argument given to this function is not a value that I could interpret. This makes it hard for me to figure out whether this was a known CSS class or not. Please transform this a string literal (\"my-class\")." ]
, under = "nonLiteral"
}
]
]
classFromAttrFunction : ClassFunction.Arguments -> List CssArgument
classFromAttrFunction { firstArgument } =
[ fromLiteral firstArgument ]
projectWithCssClasses : Project
projectWithCssClasses =
Project.addExtraFiles
(Dict.fromList
[ ( "some-file.css"
, """-- First line
.known {
color: blue;
}
.red-faint {
color: red;
}
.under_score {
color: green;
}
"""
)
]
)
Review.Test.Dependencies.projectWithElmCore
projectWithUnparsableCssClasses : Project
projectWithUnparsableCssClasses =
Project.addExtraFiles
(Dict.fromList
[ ( "some-file.css"
, """-- First line
.known {
color: blue;
}
.red-faint {
color: red;
-- missing closing curly brace
"""
)
]
)
Review.Test.Dependencies.projectWithElmCore