diff --git a/tests/NoForbiddenWords.elm b/tests/NoForbiddenWords.elm new file mode 100644 index 00000000..7b826932 --- /dev/null +++ b/tests/NoForbiddenWords.elm @@ -0,0 +1,334 @@ +module NoForbiddenWords exposing (rule) + +{-| + +@docs rule + +-} + +import Elm.Project as Project exposing (Project) +import Elm.Syntax.Declaration as Declaration exposing (Declaration) +import Elm.Syntax.Node exposing (Node(..)) +import Elm.Syntax.Range as Range exposing (Range) +import Regex exposing (Regex) +import Review.Rule as Rule exposing (Rule) + + +{-| Forbid certain words in Elm comments, README and elm.json (package summary only). + + config : List Rule + config = + [ NoForbiddenWords.rule [ "TODO", "- [ ]" ] + ] + + +## Failure Examples + +Based on the configured words `TODO` and `- [ ]` the following examples would fail: + + -- TODO: Finish writing this function + + + +Multi-line comments `{- ... -}` and documentation `{-| ... -}` also work: + + + + {- Actions + - [ ] Documentation + - [ ] Tests + -} + +-} +rule : List String -> Rule +rule words = + Rule.newProjectRuleSchema "NoForbiddenWords" () + |> Rule.withElmJsonProjectVisitor (elmJsonVisitor words) + |> Rule.withReadmeProjectVisitor (readmeVisitor words) + |> Rule.withModuleVisitor (moduleVisitor words) + |> Rule.withModuleContext + { fromModuleToProject = \_ _ () -> () + , fromProjectToModule = \_ _ () -> () + , foldProjectContexts = \() () -> () + } + |> Rule.fromProjectRuleSchema + + + +--- ELM.JSON + + +elmJsonVisitor : List String -> Maybe { elmJsonKey : Rule.ElmJsonKey, project : Project } -> () -> ( List (Rule.Error scope), () ) +elmJsonVisitor words maybeElmJson () = + case maybeElmJson of + Nothing -> + ( [], () ) + + Just elmJson -> + ( checkElmJson words elmJson, () ) + + +checkElmJson : List String -> { elmJsonKey : Rule.ElmJsonKey, project : Project } -> List (Rule.Error scope) +checkElmJson words { elmJsonKey, project } = + case project of + Project.Application _ -> + [] + + Project.Package info -> + fastConcatMap (checkElmJsonSummary elmJsonKey info.summary) words + + +checkElmJsonSummary : Rule.ElmJsonKey -> String -> String -> List (Rule.Error scope) +checkElmJsonSummary elmJsonKey summary word = + summary + |> stringNode + |> ranges word + |> List.map (elmJsonSummaryError elmJsonKey word) + + +elmJsonSummaryError : Rule.ElmJsonKey -> String -> Range -> Rule.Error scope +elmJsonSummaryError elmJsonKey word rangeInSummary = + rawElmJsonSummaryError word rangeInSummary + |> Rule.errorForElmJson elmJsonKey + + +rawElmJsonSummaryError : String -> Range -> String -> { message : String, details : List String, range : Range } +rawElmJsonSummaryError word rangeInSummary rawElmJson = + { message = "`" ++ word ++ "` is not allowed in elm.json summary." + , details = + [ "You should review your elm.json and make sure the forbidden word has been removed before publishing your code." + ] + , range = rawElmJsonSummaryRange rangeInSummary rawElmJson + } + + +rawElmJsonSummaryRange : Range -> String -> Range +rawElmJsonSummaryRange rangeInSummary rawElmJson = + rawElmJson + |> jsonFieldLocation "summary" + |> Maybe.map (rangeAddLocation rangeInSummary) + |> Maybe.withDefault startRange + + + +--- README + + +readmeVisitor : List String -> Maybe { readmeKey : Rule.ReadmeKey, content : String } -> () -> ( List (Rule.Error scope), () ) +readmeVisitor words maybeReadme () = + case maybeReadme of + Nothing -> + ( [], () ) + + Just readme -> + ( fastConcatMap (checkForbiddenReadmeWord readme) words + , () + ) + + +checkForbiddenReadmeWord : { readmeKey : Rule.ReadmeKey, content : String } -> String -> List (Rule.Error scope) +checkForbiddenReadmeWord { readmeKey, content } word = + content + |> stringNode + |> ranges word + |> List.map (forbiddenReadmeWordError readmeKey word) + + +forbiddenReadmeWordError : Rule.ReadmeKey -> String -> Range -> Rule.Error scope +forbiddenReadmeWordError readmeKey word range = + Rule.errorForReadme readmeKey + { message = "`" ++ word ++ "` is not allowed in README file." + , details = + [ "You should review this section and make sure the forbidden word has been removed before publishing your code." + ] + } + range + + + +--- MODULE + + +moduleVisitor : + List String + -> Rule.ModuleRuleSchema {} () + -> Rule.ModuleRuleSchema { hasAtLeastOneVisitor : () } () +moduleVisitor words schema = + schema + |> Rule.withSimpleCommentsVisitor (commentsVisitor words) + |> Rule.withSimpleDeclarationVisitor (declarationVisitor words) + + +commentsVisitor : List String -> List (Node String) -> List (Rule.Error {}) +commentsVisitor words comments = + fastConcatMap (commentVisitor words) comments + + +declarationVisitor : List String -> Node Declaration -> List (Rule.Error {}) +declarationVisitor words (Node _ declaration) = + case declaration of + Declaration.FunctionDeclaration { documentation } -> + documentation + |> Maybe.map (commentVisitor words) + |> Maybe.withDefault [] + + Declaration.CustomTypeDeclaration { documentation } -> + documentation + |> Maybe.map (commentVisitor words) + |> Maybe.withDefault [] + + Declaration.AliasDeclaration { documentation } -> + documentation + |> Maybe.map (commentVisitor words) + |> Maybe.withDefault [] + + _ -> + [] + + +commentVisitor : List String -> Node String -> List (Rule.Error {}) +commentVisitor words comment = + fastConcatMap (checkForbiddenWord comment) words + + +checkForbiddenWord : Node String -> String -> List (Rule.Error {}) +checkForbiddenWord comment word = + ranges word comment + |> List.map (forbiddenWordError word) + + + +--- HELPERS + + +stringNode : String -> Node String +stringNode string = + Node startRange string + + +startRange : Range +startRange = + { start = { row = 1, column = 1 } + , end = { row = 1, column = 1 } + } + + +rangeAddLocation : Range -> Range.Location -> Range +rangeAddLocation range start = + { start = + { row = start.row + range.start.row - 1 + , column = start.column + range.start.column - 1 + } + , end = + { row = start.row + range.end.row - 1 + , column = start.column + range.end.column - 1 + } + } + + +ranges : String -> Node String -> List Range +ranges needle (Node range haystack) = + String.lines haystack + |> List.indexedMap (rangesInLine needle range.start) + |> fastConcat + + +rangesInLine : String -> Range.Location -> Int -> String -> List Range +rangesInLine needle start row line = + String.indexes needle line + |> List.map (rangeFromIndex needle start row) + + +rangeFromIndex : String -> Range.Location -> Int -> Int -> Range +rangeFromIndex needle start row index = + case row of + 0 -> + { start = + { row = start.row + , column = start.column + index + } + , end = + { row = start.row + , column = start.column + index + String.length needle + } + } + + _ -> + { start = + { row = start.row + row + , column = index + 1 + } + , end = + { row = start.row + row + , column = index + 1 + String.length needle + } + } + + +jsonFieldLocation : String -> String -> Maybe Range.Location +jsonFieldLocation fieldName rawJson = + let + regex : Regex + regex = + jsonFieldRegex fieldName + in + String.lines rawJson + |> List.indexedMap (jsonFieldLocationsInLine regex) + |> fastConcat + |> List.head + + +jsonFieldLocationsInLine : Regex -> Int -> String -> List Range.Location +jsonFieldLocationsInLine regex row line = + Regex.find regex line + |> List.map (jsonFieldMatchLocation row) + + +jsonFieldMatchLocation : Int -> Regex.Match -> Range.Location +jsonFieldMatchLocation row { match, index } = + case row of + 0 -> + { row = 1 + , column = 1 + index + String.length match + } + + _ -> + { row = row + 1 + , column = index + 1 + String.length match + } + + +jsonFieldRegex : String -> Regex +jsonFieldRegex fieldName = + Regex.fromString ("\"" ++ fieldName ++ "\"\\s?:\\s?\"") + |> Maybe.withDefault Regex.never + + +forbiddenWordError : String -> Range -> Rule.Error {} +forbiddenWordError word range = + Rule.error + { message = "`" ++ word ++ "` is not allowed in comments." + , details = + [ "You should review this comment and make sure the forbidden word has been removed before publishing your code." + ] + } + range + + + +--- List Performance + + +fastConcat : List (List a) -> List a +fastConcat = + List.foldr (++) [] + + +fastConcatMap : (a -> List b) -> List a -> List b +fastConcatMap fn = + let + helper : a -> List b -> List b + helper item acc = + fn item ++ acc + in + List.foldr helper [] diff --git a/tests/Tests/NoForbiddenWords.elm b/tests/Tests/NoForbiddenWords.elm new file mode 100644 index 00000000..7c4b5c78 --- /dev/null +++ b/tests/Tests/NoForbiddenWords.elm @@ -0,0 +1,321 @@ +module Tests.NoForbiddenWords exposing (all) + +import Elm.Project +import Json.Decode as Decode +import Json.Encode as Encode +import NoForbiddenWords exposing (rule) +import Review.Project as Project exposing (Project) +import Review.Rule +import Review.Test +import Test exposing (Test, describe, test) + + +all : Test +all = + describe "NoForbiddenWords" + [ test "with no words reports nothing" <| + \() -> + """ +module A exposing (..) +-- TODO: Write main +main = Debug.todo "" +""" + |> Review.Test.run (rule []) + |> Review.Test.expectNoErrors + , test "reports any found words" <| + \() -> + """ +module A exposing (..) +-- TODO: Write main +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "reports any found words in multi-line comments" <| + \() -> + """ +module A exposing (..) +{- The entry point for our project. + +TODO: Write main + +-} +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "reports `-- [ ]` as `- [ ]`" <| + \() -> + """ +module A exposing (..) +-- [ ] Documentation +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "- [ ]" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "- [ ]" + ] + , test "reports forbidden words in module documentation" <| + \() -> + """ +module A exposing (..) +{-| Module A + +TODO: Write the documentation +-} +import Foo + +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "reports forbidden words in function documentation" <| + \() -> + """ +module A exposing (..) +{-| Module A +-} +import Foo + +{-| Main + +TODO: Write the documentation +-} +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "reports forbidden words in type documentation" <| + \() -> + """ +module A exposing (..) +import Foo + +{-| Page + +TODO: Add more pages +-} +type Page + = Page + +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "reports forbidden words in type alias documentation" <| + \() -> + """ +module A exposing (..) +import Foo + +{-| Page + +TODO: Add footer +-} +type alias Page = + { title : String, body : List (Html msg) } + +main = Debug.todo "" +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "reports forbidden words in port documentation" <| + \() -> + """ +port module Ports exposing (..) +import Foo + +{-| Save + +TODO: Use Json.Encode.Value here. +-} +port save : String -> Cmd Msg +""" + |> Review.Test.run (rule [ "TODO" ]) + |> Review.Test.expectErrors + [ forbiddenWordError "TODO" + ] + , test "checks forbidden words in README.md" <| + \() -> + let + project : Project + project = + Project.new + |> Project.addReadme + { path = "README.md" + , content = """ +# My Awesome Project + +TODO: Write the readme +""" + } + in + """ +module A exposing (..) +a = 1""" + |> Review.Test.runWithProjectData project (rule [ "TODO" ]) + |> Review.Test.expectErrorsForReadme + [ forbiddenWordErrorForReadme "TODO" + ] + , test "forbidden words in README.md can be ignored" <| + \() -> + let + project : Project + project = + Project.new + |> Project.addReadme + { path = "README.md" + , content = """ +# My Awesome Project + +TODO: Write the readme +""" + } + in + """ +module A exposing (..) +a = 1""" + |> Review.Test.runWithProjectData project + (rule [ "TODO" ] + |> Review.Rule.ignoreErrorsForFiles [ "README.md" ] + ) + |> Review.Test.expectNoErrors + , test "checks elm.json summary for forbidden words" <| + \() -> + let + project : Project + project = + Project.new + |> Project.addElmJson + (packageElmJson + { summary = "REPLACEME" + , formatted = True + } + ) + in + """ +module A exposing (..) +a = 1""" + |> Review.Test.runWithProjectData project (rule [ "REPLACEME" ]) + |> Review.Test.expectErrorsForElmJson + [ forbiddenWordErrorForElmJson "REPLACEME" + ] + , test "checks minified elm.json summary for forbidden words" <| + \() -> + let + project : Project + project = + Project.new + |> Project.addElmJson + (packageElmJson + { summary = "REPLACEME" + , formatted = False + } + ) + in + """ +module A exposing (..) +a = 1""" + |> Review.Test.runWithProjectData project (rule [ "REPLACEME" ]) + |> Review.Test.expectErrorsForElmJson + [ forbiddenWordErrorForElmJson "REPLACEME" + ] + ] + + +packageElmJson : { summary : String, formatted : Bool } -> { path : String, raw : String, project : Elm.Project.Project } +packageElmJson { summary, formatted } = + let + spaces : number + spaces = + if formatted then + 4 + + else + 0 + in + Encode.encode spaces + (Encode.object + [ ( "type", Encode.string "package" ) + , ( "name", Encode.string "sparksp/elm-review-new-package" ) + , ( "summary", Encode.string summary ) + , ( "license", Encode.string "MIT" ) + , ( "version", Encode.string "1.0.0" ) + , ( "exposed-modules", Encode.list Encode.string [ "NoNewPackage" ] ) + , ( "elm-version", Encode.string "0.19.0 <= v < 0.20.0" ) + , ( "dependencies" + , Encode.object + [ ( "elm/code", Encode.string "1.0.5 <= v <= 2.0.0" ) + ] + ) + , ( "test-dependencies", Encode.object [] ) + ] + ) + |> createElmJson + + +forbiddenWordError : String -> Review.Test.ExpectedError +forbiddenWordError word = + Review.Test.error + { message = "`" ++ word ++ "` is not allowed in comments." + , details = + [ "You should review this comment and make sure the forbidden word has been removed before publishing your code." + ] + , under = word + } + + +forbiddenWordErrorForReadme : String -> Review.Test.ExpectedError +forbiddenWordErrorForReadme word = + Review.Test.error + { message = "`" ++ word ++ "` is not allowed in README file." + , details = + [ "You should review this section and make sure the forbidden word has been removed before publishing your code." + ] + , under = word + } + + +forbiddenWordErrorForElmJson : String -> Review.Test.ExpectedError +forbiddenWordErrorForElmJson word = + Review.Test.error + { message = "`" ++ word ++ "` is not allowed in elm.json summary." + , details = + [ "You should review your elm.json and make sure the forbidden word has been removed before publishing your code." + ] + , under = word + } + + + +--- HELPERS + + +createElmJson : String -> { path : String, raw : String, project : Elm.Project.Project } +createElmJson rawElmJson = + { path = "elm.json" + , raw = rawElmJson + , project = createElmJsonProject rawElmJson + } + + +createElmJsonProject : String -> Elm.Project.Project +createElmJsonProject rawElmJson = + case Decode.decodeString Elm.Project.decoder rawElmJson of + Ok project -> + project + + Err error -> + Debug.todo ("[elm.json]: " ++ Debug.toString error)