mirror of
https://github.com/jfmengels/elm-review.git
synced 2024-08-16 19:40:22 +03:00
Backport elm-review-documentation
This commit is contained in:
parent
fd2eda59d4
commit
34798e3f49
3
elm.json
3
elm.json
@ -22,6 +22,7 @@
|
||||
"stil4m/elm-syntax": "7.2.7 <= v < 8.0.0"
|
||||
},
|
||||
"test-dependencies": {
|
||||
"elm/regex": "1.0.0 <= v < 2.0.0"
|
||||
"elm/regex": "1.0.0 <= v < 2.0.0",
|
||||
"elm/parser": "1.1.0 <= v < 2.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@
|
||||
"direct": {
|
||||
"elm/core": "1.0.5",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/parser": "1.1.0",
|
||||
"elm/project-metadata-utils": "1.0.2",
|
||||
"elm/regex": "1.0.0",
|
||||
"jfmengels/elm-review": "2.4.0",
|
||||
@ -16,7 +17,6 @@
|
||||
},
|
||||
"indirect": {
|
||||
"elm/html": "1.0.0",
|
||||
"elm/parser": "1.1.0",
|
||||
"elm/random": "1.0.0",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
|
@ -11,7 +11,7 @@ when inside the directory containing this file.
|
||||
|
||||
-}
|
||||
|
||||
import Documentation.ReadmeLinksPointToCurrentVersion
|
||||
import Docs.UpToDateReadmeLinks
|
||||
import NoDebug.Log
|
||||
import NoDebug.TodoOrToString
|
||||
import NoExposingEverything
|
||||
@ -32,7 +32,7 @@ import Review.Rule as Rule exposing (Rule)
|
||||
|
||||
config : List Rule
|
||||
config =
|
||||
[ Documentation.ReadmeLinksPointToCurrentVersion.rule
|
||||
[ Docs.UpToDateReadmeLinks.rule
|
||||
, NoDebug.Log.rule
|
||||
, NoDebug.TodoOrToString.rule
|
||||
|> Rule.ignoreErrorsForDirectories [ "tests/" ]
|
||||
|
428
tests/Docs/NoMissing.elm
Normal file
428
tests/Docs/NoMissing.elm
Normal file
@ -0,0 +1,428 @@
|
||||
module Docs.NoMissing exposing
|
||||
( rule
|
||||
, What, everything, onlyExposed
|
||||
, From, allModules, exposedModules
|
||||
)
|
||||
|
||||
{-|
|
||||
|
||||
@docs rule
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
@docs What, everything, onlyExposed
|
||||
@docs From, allModules, exposedModules
|
||||
|
||||
|
||||
## When (not) to enable this rule
|
||||
|
||||
This rule is useful when you care about having a thoroughly or increasingly documented project.
|
||||
It is also useful when you write Elm packages, in order to know about missing documentation before you publish.
|
||||
|
||||
|
||||
## Try it out
|
||||
|
||||
You can try this rule out by running the following command:
|
||||
|
||||
```bash
|
||||
elm-review --template jfmengels/elm-review-documentation/example --rules Docs.NoMissing
|
||||
```
|
||||
|
||||
-}
|
||||
|
||||
import Docs.Utils.ExposedFromProject as ExposedFromProject
|
||||
import Elm.Project
|
||||
import Elm.Syntax.Declaration as Declaration exposing (Declaration)
|
||||
import Elm.Syntax.Exposing as Exposing
|
||||
import Elm.Syntax.Module as Module exposing (Module)
|
||||
import Elm.Syntax.Node as Node exposing (Node(..))
|
||||
import Elm.Syntax.Range as Range
|
||||
import Review.Rule as Rule exposing (Error, Rule)
|
||||
import Set exposing (Set)
|
||||
|
||||
|
||||
{-| Reports missing or empty documentation for functions and types.
|
||||
|
||||
import Docs.NoMissing exposing (exposedModules, onlyExposed)
|
||||
|
||||
config =
|
||||
[ Docs.NoMissing.rule
|
||||
{ document = onlyExposed
|
||||
, from = exposedModules
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
## Fail
|
||||
|
||||
The rule will report when documentation is missing
|
||||
|
||||
someFunction =
|
||||
great Things
|
||||
|
||||
or when documentation is present but empty.
|
||||
|
||||
{-| -}
|
||||
someOtherFunction =
|
||||
other (great Things)
|
||||
|
||||
The reasoning for not allowing empty documentation is because of how people consume documentation can vary, and for some
|
||||
of those ways, empty documentation doesn't lead to a nice experience. For instance, if you are looking at Elm code in
|
||||
your IDE and want to lookup the definition of a function, an empty documentation will give you no information beyond the
|
||||
type annotation.
|
||||
|
||||
When I write documentation for a module, I try to tell a story or somehow phrase it as a tutorial, so that people can
|
||||
learn the easier concepts first, and gradually as they read more and learn more about the ideas and concepts, I will
|
||||
assume that they read most of the documentation above.
|
||||
|
||||
But for every function or type, I also imagine that they'll be read on their own from an IDE for instance, and therefore
|
||||
try to make the documentation as light as possible while giving a helpful description and an example, without relying
|
||||
too much on the assumption that the user has read the rest of the module.
|
||||
|
||||
A common case where people don't give an example is when exposing functions such as `map2`, `map3`, `map4`, etc., usually
|
||||
documented in that order and next to each other. While `map2` is usually properly documented, the following ones would
|
||||
have empty documentation, which I believe would be because the author assumes that the user went through the documentation on
|
||||
the package registry and has read the documentation for `map2`. But if someone unfamiliar with Elm or an API looks up
|
||||
`map3`, they may have trouble finding the information they were looking for.
|
||||
|
||||
I would recommend to make the documentation for each element as understandable out of context as possible. At the very least,
|
||||
I would advise to say something like "This function is like `map2` but with X arguments" with a link to `map2`, so that
|
||||
relevant information _can_ be found without too much effort.
|
||||
|
||||
|
||||
## Success
|
||||
|
||||
{-| someFunction does great things
|
||||
-}
|
||||
someFunction =
|
||||
great Things
|
||||
|
||||
-}
|
||||
rule : { document : What, from : From } -> Rule
|
||||
rule configuration =
|
||||
Rule.newModuleRuleSchema "Docs.NoMissing" initialContext
|
||||
|> Rule.withElmJsonModuleVisitor elmJsonVisitor
|
||||
|> Rule.withModuleDefinitionVisitor (moduleDefinitionVisitor configuration.from)
|
||||
|> Rule.withCommentsVisitor commentsVisitor
|
||||
|> Rule.withDeclarationEnterVisitor (declarationVisitor configuration.document)
|
||||
|> Rule.fromModuleRuleSchema
|
||||
|
||||
|
||||
type alias Context =
|
||||
{ moduleNameNode : Node String
|
||||
, exposedModules : Set String
|
||||
, exposedElements : Exposed
|
||||
, shouldBeReported : Bool
|
||||
}
|
||||
|
||||
|
||||
initialContext : Context
|
||||
initialContext =
|
||||
{ moduleNameNode = Node Range.emptyRange ""
|
||||
, exposedModules = Set.empty
|
||||
, exposedElements = EverythingIsExposed
|
||||
, shouldBeReported = True
|
||||
}
|
||||
|
||||
|
||||
type Exposed
|
||||
= EverythingIsExposed
|
||||
| ExplicitList (Set String)
|
||||
|
||||
|
||||
{-| Which elements from a module should be documented. Possible options are [`everything`](#everything) in a module or
|
||||
only the exposed elements of a module ([`onlyExposed`](#onlyExposed)).
|
||||
-}
|
||||
type What
|
||||
= Everything
|
||||
| OnlyExposed
|
||||
|
||||
|
||||
{-| Every function and type from a module should be documented. The module definition should also be documented.
|
||||
-}
|
||||
everything : What
|
||||
everything =
|
||||
Everything
|
||||
|
||||
|
||||
{-| Only exposed functions and types from a module should be documented. The module definition should also be documented.
|
||||
-}
|
||||
onlyExposed : What
|
||||
onlyExposed =
|
||||
OnlyExposed
|
||||
|
||||
|
||||
{-| Which modules should be documented. Possible options are [`allModules`](#allModules) of a project or
|
||||
only the [`exposedModules`](#exposedModules) (only for packages).
|
||||
-}
|
||||
type From
|
||||
= AllModules
|
||||
| ExposedModules
|
||||
|
||||
|
||||
{-| All modules from the project should be documented.
|
||||
-}
|
||||
allModules : From
|
||||
allModules =
|
||||
AllModules
|
||||
|
||||
|
||||
{-| Only exposed modules from the project will need to be documented.
|
||||
|
||||
If your project is an application, you should not use this option. An application does not expose modules which would
|
||||
mean there isn't any module to report errors for.
|
||||
|
||||
-}
|
||||
exposedModules : From
|
||||
exposedModules =
|
||||
-- TODO Report a global error if used inside an application
|
||||
ExposedModules
|
||||
|
||||
|
||||
|
||||
-- ELM.JSON VISITOR
|
||||
|
||||
|
||||
elmJsonVisitor : Maybe Elm.Project.Project -> Context -> Context
|
||||
elmJsonVisitor maybeProject context =
|
||||
let
|
||||
exposedModules_ : Set String
|
||||
exposedModules_ =
|
||||
case maybeProject of
|
||||
Just project ->
|
||||
ExposedFromProject.exposedModules project
|
||||
|
||||
_ ->
|
||||
Set.empty
|
||||
in
|
||||
{ context | exposedModules = exposedModules_ }
|
||||
|
||||
|
||||
|
||||
-- MODULE DEFINITION VISITOR
|
||||
|
||||
|
||||
moduleDefinitionVisitor : From -> Node Module -> Context -> ( List nothing, Context )
|
||||
moduleDefinitionVisitor fromConfig node context =
|
||||
let
|
||||
moduleNameNode : Node String
|
||||
moduleNameNode =
|
||||
case Node.value node of
|
||||
Module.NormalModule x ->
|
||||
Node
|
||||
(Node.range x.moduleName)
|
||||
(Node.value x.moduleName |> String.join ".")
|
||||
|
||||
Module.PortModule x ->
|
||||
Node
|
||||
(Node.range x.moduleName)
|
||||
(Node.value x.moduleName |> String.join ".")
|
||||
|
||||
Module.EffectModule x ->
|
||||
Node
|
||||
(Node.range x.moduleName)
|
||||
(Node.value x.moduleName |> String.join ".")
|
||||
|
||||
shouldBeReported : Bool
|
||||
shouldBeReported =
|
||||
case fromConfig of
|
||||
AllModules ->
|
||||
True
|
||||
|
||||
ExposedModules ->
|
||||
Set.member (Node.value moduleNameNode) context.exposedModules
|
||||
|
||||
exposed : Exposed
|
||||
exposed =
|
||||
case Node.value node |> Module.exposingList of
|
||||
Exposing.All _ ->
|
||||
EverythingIsExposed
|
||||
|
||||
Exposing.Explicit list ->
|
||||
ExplicitList (List.map collectExposing list |> Set.fromList)
|
||||
in
|
||||
( []
|
||||
, { context
|
||||
| moduleNameNode = moduleNameNode
|
||||
, shouldBeReported = shouldBeReported
|
||||
, exposedElements = exposed
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
collectExposing : Node Exposing.TopLevelExpose -> String
|
||||
collectExposing node =
|
||||
case Node.value node of
|
||||
Exposing.InfixExpose name ->
|
||||
name
|
||||
|
||||
Exposing.FunctionExpose name ->
|
||||
name
|
||||
|
||||
Exposing.TypeOrAliasExpose name ->
|
||||
name
|
||||
|
||||
Exposing.TypeExpose exposedType ->
|
||||
exposedType.name
|
||||
|
||||
|
||||
|
||||
-- COMMENTS VISITOR
|
||||
|
||||
|
||||
commentsVisitor : List (Node String) -> Context -> ( List (Error {}), Context )
|
||||
commentsVisitor comments context =
|
||||
if context.shouldBeReported then
|
||||
let
|
||||
documentation : Maybe (Node String)
|
||||
documentation =
|
||||
findFirst (Node.value >> String.startsWith "{-|") comments
|
||||
in
|
||||
( checkModuleDocumentation documentation context.moduleNameNode
|
||||
, context
|
||||
)
|
||||
|
||||
else
|
||||
( [], context )
|
||||
|
||||
|
||||
findFirst : (a -> Bool) -> List a -> Maybe a
|
||||
findFirst predicate list =
|
||||
case list of
|
||||
[] ->
|
||||
Nothing
|
||||
|
||||
a :: rest ->
|
||||
if predicate a then
|
||||
Just a
|
||||
|
||||
else
|
||||
findFirst predicate rest
|
||||
|
||||
|
||||
|
||||
-- DECLARATION VISITOR
|
||||
|
||||
|
||||
declarationVisitor : What -> Node Declaration -> Context -> ( List (Error {}), Context )
|
||||
declarationVisitor documentWhat node context =
|
||||
if context.shouldBeReported then
|
||||
( reportDeclarationDocumentation documentWhat context node
|
||||
, context
|
||||
)
|
||||
|
||||
else
|
||||
( [], context )
|
||||
|
||||
|
||||
reportDeclarationDocumentation : What -> Context -> Node Declaration -> List (Error {})
|
||||
reportDeclarationDocumentation documentWhat context node =
|
||||
case Node.value node of
|
||||
Declaration.FunctionDeclaration { documentation, declaration } ->
|
||||
let
|
||||
nameNode : Node String
|
||||
nameNode =
|
||||
(Node.value declaration).name
|
||||
in
|
||||
if shouldBeDocumented documentWhat context (Node.value nameNode) then
|
||||
checkElementDocumentation documentation nameNode
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
Declaration.CustomTypeDeclaration { documentation, name } ->
|
||||
if shouldBeDocumented documentWhat context (Node.value name) then
|
||||
checkElementDocumentation documentation name
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
Declaration.AliasDeclaration { documentation, name } ->
|
||||
if shouldBeDocumented documentWhat context (Node.value name) then
|
||||
checkElementDocumentation documentation name
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
_ ->
|
||||
[]
|
||||
|
||||
|
||||
shouldBeDocumented : What -> Context -> String -> Bool
|
||||
shouldBeDocumented documentWhat context name =
|
||||
case documentWhat of
|
||||
Everything ->
|
||||
True
|
||||
|
||||
OnlyExposed ->
|
||||
case context.exposedElements of
|
||||
EverythingIsExposed ->
|
||||
True
|
||||
|
||||
ExplicitList exposedElements ->
|
||||
Set.member name exposedElements
|
||||
|
||||
|
||||
checkModuleDocumentation : Maybe (Node String) -> Node String -> List (Error {})
|
||||
checkModuleDocumentation documentation nameNode =
|
||||
case documentation of
|
||||
Just doc ->
|
||||
if isDocumentationEmpty doc then
|
||||
[ Rule.error
|
||||
{ message = "The documentation for module `" ++ Node.value nameNode ++ "` is empty"
|
||||
, details = [ "Empty documentation is not useful for the users. Please give explanations or examples." ]
|
||||
}
|
||||
(Node.range doc)
|
||||
]
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
Nothing ->
|
||||
[ Rule.error
|
||||
{ message = "Missing documentation for module `" ++ Node.value nameNode ++ "`"
|
||||
, details = documentationErrorDetails
|
||||
}
|
||||
(Node.range nameNode)
|
||||
]
|
||||
|
||||
|
||||
documentationErrorDetails : List String
|
||||
documentationErrorDetails =
|
||||
[ "A module documentation summarizes what a module is for, the responsibilities it has and how to use it. Providing a good module documentation will be useful for your users or colleagues."
|
||||
]
|
||||
|
||||
|
||||
checkElementDocumentation : Maybe (Node String) -> Node String -> List (Error {})
|
||||
checkElementDocumentation documentation nameNode =
|
||||
case documentation of
|
||||
Just doc ->
|
||||
if isDocumentationEmpty doc then
|
||||
[ Rule.error
|
||||
{ message = "The documentation for `" ++ Node.value nameNode ++ "` is empty"
|
||||
, details = [ "Empty documentation is not useful for the users. Please give explanations or examples." ]
|
||||
}
|
||||
(Node.range doc)
|
||||
]
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
Nothing ->
|
||||
[ Rule.error
|
||||
{ message = "Missing documentation for `" ++ Node.value nameNode ++ "`"
|
||||
, details = [ "Documentation can help developers use this API." ]
|
||||
}
|
||||
(Node.range nameNode)
|
||||
]
|
||||
|
||||
|
||||
isDocumentationEmpty : Node String -> Bool
|
||||
isDocumentationEmpty doc =
|
||||
doc
|
||||
|> Node.value
|
||||
|> String.dropLeft 3
|
||||
|> String.dropRight 2
|
||||
|> String.trim
|
||||
|> String.isEmpty
|
369
tests/Docs/NoMissingTest.elm
Normal file
369
tests/Docs/NoMissingTest.elm
Normal file
@ -0,0 +1,369 @@
|
||||
module Docs.NoMissingTest exposing (all)
|
||||
|
||||
import Docs.NoMissing exposing (rule)
|
||||
import Elm.Project
|
||||
import Json.Decode as Decode
|
||||
import Review.Project as Project exposing (Project)
|
||||
import Review.Test
|
||||
import Test exposing (Test, describe, test)
|
||||
|
||||
|
||||
missingModuleDetails : List String
|
||||
missingModuleDetails =
|
||||
[ "A module documentation summarizes what a module is for, the responsibilities it has and how to use it. Providing a good module documentation will be useful for your users or colleagues."
|
||||
]
|
||||
|
||||
|
||||
missingElementDetails : List String
|
||||
missingElementDetails =
|
||||
[ "Documentation can help developers use this API." ]
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Docs.NoMissing"
|
||||
[ everythingEverywhereTests
|
||||
, everythingFromExposedModulesTests
|
||||
, onlyExposedFromExposedModulesTests
|
||||
]
|
||||
|
||||
|
||||
everythingEverywhereTests : Test
|
||||
everythingEverywhereTests =
|
||||
let
|
||||
config : { document : Docs.NoMissing.What, from : Docs.NoMissing.From }
|
||||
config =
|
||||
{ document = Docs.NoMissing.everything
|
||||
, from = Docs.NoMissing.allModules
|
||||
}
|
||||
in
|
||||
describe "document everything - from everywhere"
|
||||
[ test "should report an error when a function does not have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
|
||||
function = 1
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for `function`"
|
||||
, details = missingElementDetails
|
||||
, under = "function"
|
||||
}
|
||||
]
|
||||
, test "should not report an error when a function does have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
|
||||
{-| documentation -}
|
||||
function = 1
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error when a function's documentation is empty" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
|
||||
{-| -}
|
||||
function = 1
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "The documentation for `function` is empty"
|
||||
, details = [ "Empty documentation is not useful for the users. Please give explanations or examples." ]
|
||||
, under = "{-| -}"
|
||||
}
|
||||
]
|
||||
, test "should report an error when a custom type does not have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
type CustomType = A
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for `CustomType`"
|
||||
, details = missingElementDetails
|
||||
, under = "CustomType"
|
||||
}
|
||||
]
|
||||
, test "should not report an error when a custom type does have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
{-| documentation -}
|
||||
type CustomType = A
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error when a custom type's documentation is empty" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
{-| -}
|
||||
type CustomType = A
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "The documentation for `CustomType` is empty"
|
||||
, details = [ "Empty documentation is not useful for the users. Please give explanations or examples." ]
|
||||
, under = "{-| -}"
|
||||
}
|
||||
]
|
||||
, test "should report an error when a type alias does not have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
type alias Alias = A
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for `Alias`"
|
||||
, details = missingElementDetails
|
||||
, under = "Alias"
|
||||
}
|
||||
]
|
||||
, test "should not report an error when a type alias does have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
{-| documentation -}
|
||||
type alias Alias = A
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error when a type alias' documentation is empty" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| module documentation -}
|
||||
import Thing
|
||||
{-| -}
|
||||
type alias Alias = A
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "The documentation for `Alias` is empty"
|
||||
, details = [ "Empty documentation is not useful for the users. Please give explanations or examples." ]
|
||||
, under = "{-| -}"
|
||||
}
|
||||
]
|
||||
, test "should report an error when a module does not have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
import Thing
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for module `A`"
|
||||
, details = missingModuleDetails
|
||||
, under = "A"
|
||||
}
|
||||
]
|
||||
, test "should not report an error when a module does have documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| documentation -}
|
||||
import Thing
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error when the module's documentation is empty" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
{-| -}
|
||||
import Thing
|
||||
"""
|
||||
|> Review.Test.run (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "The documentation for module `A` is empty"
|
||||
, details = [ "Empty documentation is not useful for the users. Please give explanations or examples." ]
|
||||
, under = "{-| -}"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
everythingFromExposedModulesTests : Test
|
||||
everythingFromExposedModulesTests =
|
||||
let
|
||||
config : { document : Docs.NoMissing.What, from : Docs.NoMissing.From }
|
||||
config =
|
||||
{ document = Docs.NoMissing.everything
|
||||
, from = Docs.NoMissing.exposedModules
|
||||
}
|
||||
in
|
||||
describe "document everything - from exposed modules"
|
||||
[ test "should not report things from non-exposed modules for a package" <|
|
||||
\() ->
|
||||
"""module NotExposed exposing (..)
|
||||
import Thing
|
||||
"""
|
||||
|> Review.Test.runWithProjectData packageProject (rule config)
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report things from exposed modules for a package" <|
|
||||
\() ->
|
||||
"""module Exposed exposing (..)
|
||||
import Thing
|
||||
"""
|
||||
|> Review.Test.runWithProjectData packageProject (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for module `Exposed`"
|
||||
, details = missingModuleDetails
|
||||
, under = "Exposed"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
onlyExposedFromExposedModulesTests : Test
|
||||
onlyExposedFromExposedModulesTests =
|
||||
let
|
||||
config : { document : Docs.NoMissing.What, from : Docs.NoMissing.From }
|
||||
config =
|
||||
{ document = Docs.NoMissing.onlyExposed
|
||||
, from = Docs.NoMissing.exposedModules
|
||||
}
|
||||
in
|
||||
describe "document only exposed - from exposed modules"
|
||||
[ test "should not report non-exposed elements from exposed modules" <|
|
||||
\() ->
|
||||
"""module Exposed exposing (a)
|
||||
|
||||
{-| module
|
||||
-}
|
||||
import Thing
|
||||
|
||||
{-| a
|
||||
-}
|
||||
a : ()
|
||||
a = ()
|
||||
|
||||
b = ()
|
||||
"""
|
||||
|> Review.Test.runWithProjectData packageProject (rule config)
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report exposed elements from exposed modules, using exposing everything" <|
|
||||
\() ->
|
||||
"""module Exposed exposing (..)
|
||||
import Thing
|
||||
function = 1
|
||||
type CustomType = Variant
|
||||
type alias Alias = A
|
||||
"""
|
||||
|> Review.Test.runWithProjectData packageProject (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for module `Exposed`"
|
||||
, details = missingModuleDetails
|
||||
, under = "Exposed"
|
||||
}
|
||||
, Review.Test.error
|
||||
{ message = "Missing documentation for `function`"
|
||||
, details = missingElementDetails
|
||||
, under = "function"
|
||||
}
|
||||
, Review.Test.error
|
||||
{ message = "Missing documentation for `CustomType`"
|
||||
, details = missingElementDetails
|
||||
, under = "CustomType"
|
||||
}
|
||||
, Review.Test.error
|
||||
{ message = "Missing documentation for `Alias`"
|
||||
, details = missingElementDetails
|
||||
, under = "Alias"
|
||||
}
|
||||
]
|
||||
, test "should report exposed elements from exposed modules, using explicit exposing" <|
|
||||
\() ->
|
||||
"""module Exposed exposing (function, CustomType, Alias)
|
||||
import Thing
|
||||
function = 1
|
||||
type CustomType = Variant
|
||||
type alias Alias = A
|
||||
"""
|
||||
|> Review.Test.runWithProjectData packageProject (rule config)
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing documentation for module `Exposed`"
|
||||
, details = missingModuleDetails
|
||||
, under = "Exposed"
|
||||
}
|
||||
, Review.Test.error
|
||||
{ message = "Missing documentation for `function`"
|
||||
, details = missingElementDetails
|
||||
, under = "function"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 3, column = 1 }, end = { row = 3, column = 9 } }
|
||||
, Review.Test.error
|
||||
{ message = "Missing documentation for `CustomType`"
|
||||
, details = missingElementDetails
|
||||
, under = "CustomType"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 4, column = 6 }, end = { row = 4, column = 16 } }
|
||||
, Review.Test.error
|
||||
{ message = "Missing documentation for `Alias`"
|
||||
, details = missingElementDetails
|
||||
, under = "Alias"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 5, column = 12 }, end = { row = 5, column = 17 } }
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
packageProject : Project
|
||||
packageProject =
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson packageElmJson)
|
||||
|
||||
|
||||
packageElmJson : String
|
||||
packageElmJson =
|
||||
"""
|
||||
{
|
||||
"type": "package",
|
||||
"name": "author/package",
|
||||
"summary": "Summary",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "1.0.0",
|
||||
"exposed-modules": [
|
||||
"Exposed"
|
||||
],
|
||||
"elm-version": "0.19.0 <= v < 0.20.0",
|
||||
"dependencies": {
|
||||
"elm/core": "1.0.0 <= v < 2.0.0"
|
||||
},
|
||||
"test-dependencies": {}
|
||||
}"""
|
||||
|
||||
|
||||
createElmJson : String -> { path : String, raw : String, project : Elm.Project.Project }
|
||||
createElmJson rawElmJson =
|
||||
case Decode.decodeString Elm.Project.decoder rawElmJson of
|
||||
Ok elmJson ->
|
||||
{ path = "elm.json"
|
||||
, raw = rawElmJson
|
||||
, project = elmJson
|
||||
}
|
||||
|
||||
Err _ ->
|
||||
Debug.todo "Invalid elm.json supplied to test"
|
473
tests/Docs/ReviewAtDocs.elm
Normal file
473
tests/Docs/ReviewAtDocs.elm
Normal file
@ -0,0 +1,473 @@
|
||||
module Docs.ReviewAtDocs exposing (rule)
|
||||
|
||||
{-|
|
||||
|
||||
@docs rule
|
||||
|
||||
-}
|
||||
|
||||
import Dict
|
||||
import Docs.Utils.ExposedFromProject as ExposedFromProject
|
||||
import Elm.Project
|
||||
import Elm.Syntax.Declaration as Declaration exposing (Declaration)
|
||||
import Elm.Syntax.Exposing as Exposing exposing (Exposing)
|
||||
import Elm.Syntax.Module as Module exposing (Module)
|
||||
import Elm.Syntax.Node as Node exposing (Node(..))
|
||||
import Elm.Syntax.Range as Range exposing (Range)
|
||||
import Parser exposing ((|.), (|=), Parser)
|
||||
import Review.Rule as Rule exposing (Rule)
|
||||
import Set exposing (Set)
|
||||
|
||||
|
||||
|
||||
-- TODO Report @docs Thing(..) like in:
|
||||
-- https://github.com/Holmusk/swagger-decoder/blob/1.0.0/src/Swagger/Types.elm
|
||||
-- https://package.elm-lang.org/packages/Holmusk/swagger-decoder/latest/Swagger-Types#Scheme
|
||||
-- TODO Report https://github.com/elm/package.elm-lang.org/issues/311
|
||||
-- TODO Report https://github.com/elm/package.elm-lang.org/issues/216
|
||||
-- TODO Report @docs in README?
|
||||
|
||||
|
||||
{-| Reports problems with the usages of `@docs`.
|
||||
|
||||
config =
|
||||
[ Docs.ReviewAtDocs.rule
|
||||
]
|
||||
|
||||
The aim of this rule is to report problems for documentation in packages that the Elm compiler doesn't report but that
|
||||
break documentation, and to replicate the same checks for applications so that you can write documentation without
|
||||
worrying about them getting stale.
|
||||
|
||||
The rule will report issues with malformed `@docs` directives that will cause the documentation to not be displayed properly once published.
|
||||
|
||||
- `@docs` on the first line
|
||||
|
||||
```elm
|
||||
{-|
|
||||
|
||||
@docs a
|
||||
|
||||
-}
|
||||
```
|
||||
|
||||
- Indented `@docs`
|
||||
|
||||
```elm
|
||||
{-|
|
||||
|
||||
@docs a
|
||||
|
||||
-}
|
||||
```
|
||||
|
||||
Once there are no more issues of malformed `@docs`, the rule will report about:
|
||||
|
||||
- Missing `@docs` for exposed elements
|
||||
|
||||
- `@docs` for non-exposed or missing elements
|
||||
|
||||
- Duplicate `@docs` references
|
||||
|
||||
- Usage of `@docs` outside of the module documentation
|
||||
|
||||
If a module does not have _any_ usage of `@docs`, then the rule will not report anything, as the rule will assume the
|
||||
module is not meant to be documented at this moment in time. An exception is made for exposed modules of a package.
|
||||
|
||||
|
||||
## When (not) to enable this rule
|
||||
|
||||
This rule will not be useful if your project is an application and no-one in the team has the habit of writing
|
||||
package-like documentation.
|
||||
|
||||
|
||||
## Try it out
|
||||
|
||||
You can try this rule out by running the following command:
|
||||
|
||||
```bash
|
||||
elm-review --template jfmengels/elm-review-documentation/example --rules Docs.ReviewAtDocs
|
||||
```
|
||||
|
||||
-}
|
||||
rule : Rule
|
||||
rule =
|
||||
Rule.newModuleRuleSchema "Docs.ReviewAtDocs" initialContext
|
||||
|> Rule.withElmJsonModuleVisitor elmJsonVisitor
|
||||
|> Rule.withModuleDefinitionVisitor moduleDefinitionVisitor
|
||||
|> Rule.withCommentsVisitor commentsVisitor
|
||||
|> Rule.withDeclarationListVisitor (\nodes context -> ( declarationListVisitor nodes context, context ))
|
||||
|> Rule.fromModuleRuleSchema
|
||||
|
||||
|
||||
type alias Context =
|
||||
{ exposedModulesFromProject : Set String
|
||||
, moduleIsExposed : Bool
|
||||
, exposedFromModule : Exposing
|
||||
, hasMalformedDocs : Bool
|
||||
, docsReferences : List (Node String)
|
||||
}
|
||||
|
||||
|
||||
initialContext : Context
|
||||
initialContext =
|
||||
{ exposedModulesFromProject = Set.empty
|
||||
, moduleIsExposed = False
|
||||
, exposedFromModule = Exposing.All Range.emptyRange
|
||||
, hasMalformedDocs = False
|
||||
, docsReferences = []
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- ELM.JSON VISITOR
|
||||
|
||||
|
||||
elmJsonVisitor : Maybe Elm.Project.Project -> Context -> Context
|
||||
elmJsonVisitor maybeProject context =
|
||||
let
|
||||
exposedModules : Set String
|
||||
exposedModules =
|
||||
case maybeProject of
|
||||
Just project ->
|
||||
ExposedFromProject.exposedModules project
|
||||
|
||||
_ ->
|
||||
Set.empty
|
||||
in
|
||||
{ context | exposedModulesFromProject = exposedModules }
|
||||
|
||||
|
||||
|
||||
-- MODULE DEFINITION VISITOR
|
||||
|
||||
|
||||
moduleDefinitionVisitor : Node Module -> Context -> ( List nothing, Context )
|
||||
moduleDefinitionVisitor node context =
|
||||
( []
|
||||
, { context
|
||||
| exposedFromModule = Module.exposingList (Node.value node)
|
||||
, moduleIsExposed = Set.member (Module.moduleName (Node.value node) |> String.join ".") context.exposedModulesFromProject
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- COMMENTS VISITOR
|
||||
|
||||
|
||||
commentsVisitor : List (Node String) -> Context -> ( List (Rule.Error {}), Context )
|
||||
commentsVisitor nodes context =
|
||||
case find (Node.value >> String.startsWith "{-|") nodes of
|
||||
Just (Node range comment) ->
|
||||
case String.lines comment of
|
||||
firstLine :: restOfLines ->
|
||||
let
|
||||
( linesThatStartWithAtDocs, linesThatDontStartWithAtDocs ) =
|
||||
restOfLines
|
||||
|> List.indexedMap (\index line -> ( index + range.start.row + 1, line ))
|
||||
|> List.partition (Tuple.second >> String.startsWith "@docs ")
|
||||
|
||||
misformedDocsErrors : List (Rule.Error {})
|
||||
misformedDocsErrors =
|
||||
List.append
|
||||
(reportDocsOnFirstLine range.start.row firstLine)
|
||||
(List.concatMap reportIndentedDocs linesThatDontStartWithAtDocs)
|
||||
in
|
||||
( misformedDocsErrors
|
||||
, { context
|
||||
| docsReferences = List.concatMap collectDocStatements linesThatStartWithAtDocs
|
||||
, hasMalformedDocs = not (List.isEmpty misformedDocsErrors)
|
||||
}
|
||||
)
|
||||
|
||||
[] ->
|
||||
( [], context )
|
||||
|
||||
Nothing ->
|
||||
( [], context )
|
||||
|
||||
|
||||
reportDocsOnFirstLine : Int -> String -> List (Rule.Error {})
|
||||
reportDocsOnFirstLine lineNumber line =
|
||||
Parser.run (Parser.succeed identity |. Parser.keyword "{-|" |= docsWithSpacesParser lineNumber) line
|
||||
|> Result.map
|
||||
(\range ->
|
||||
[ Rule.error
|
||||
{ message = "Found @docs on the first line"
|
||||
, details = [ "Using @docs on the first line will make for a broken documentation once published. Please move it to the beginning of the next line." ]
|
||||
}
|
||||
range
|
||||
]
|
||||
)
|
||||
|> Result.withDefault []
|
||||
|
||||
|
||||
reportIndentedDocs : ( Int, String ) -> List (Rule.Error {})
|
||||
reportIndentedDocs ( lineNumber, line ) =
|
||||
Parser.run (docsWithSpacesParser lineNumber) line
|
||||
|> Result.map
|
||||
(\range ->
|
||||
[ Rule.error
|
||||
{ message = "Found indented @docs"
|
||||
, details = [ "@docs need to be at the beginning of a line, otherwise they can lead to broken documentation once published. on the first line will make for a broken documentation once published. Please remove the leading spaces" ]
|
||||
}
|
||||
range
|
||||
]
|
||||
)
|
||||
|> Result.withDefault []
|
||||
|
||||
|
||||
docsWithSpacesParser : Int -> Parser Range
|
||||
docsWithSpacesParser row =
|
||||
Parser.succeed
|
||||
(\startColumn endColumn ->
|
||||
{ start = { row = row, column = startColumn }, end = { row = row, column = endColumn } }
|
||||
)
|
||||
|. Parser.spaces
|
||||
|= Parser.getCol
|
||||
|. Parser.keyword "@docs"
|
||||
|= Parser.getCol
|
||||
|
||||
|
||||
collectDocStatements : ( Int, String ) -> List (Node String)
|
||||
collectDocStatements ( lineNumber, string ) =
|
||||
Parser.run (docElementsParser lineNumber) string
|
||||
|> Result.withDefault []
|
||||
|
||||
|
||||
docElementsParser : Int -> Parser (List (Node String))
|
||||
docElementsParser startRow =
|
||||
Parser.succeed identity
|
||||
|. Parser.keyword "@docs"
|
||||
|. Parser.spaces
|
||||
|= Parser.sequence
|
||||
{ start = ""
|
||||
, separator = ","
|
||||
, end = ""
|
||||
, spaces = Parser.spaces
|
||||
, item = docsItemParser startRow
|
||||
, trailing = Parser.Forbidden
|
||||
}
|
||||
|
||||
|
||||
docsItemParser : Int -> Parser (Node String)
|
||||
docsItemParser row =
|
||||
Parser.succeed
|
||||
(\startColumn name endColumn ->
|
||||
Node
|
||||
{ start = { row = row, column = startColumn }
|
||||
, end = { row = row, column = endColumn }
|
||||
}
|
||||
name
|
||||
)
|
||||
|= Parser.getCol
|
||||
|= Parser.variable
|
||||
{ start = Char.isAlpha
|
||||
, inner = \c -> Char.isAlphaNum c || c == '_'
|
||||
, reserved = Set.empty
|
||||
}
|
||||
|= Parser.getCol
|
||||
|
||||
|
||||
|
||||
-- DECLARATION LIST VISITOR
|
||||
|
||||
|
||||
declarationListVisitor : List (Node Declaration) -> Context -> List (Rule.Error {})
|
||||
declarationListVisitor nodes context =
|
||||
if context.hasMalformedDocs || (List.isEmpty context.docsReferences && not context.moduleIsExposed) then
|
||||
List.concatMap errorsForDocsInDeclarationDoc nodes
|
||||
|
||||
else
|
||||
let
|
||||
exposedNodes : List (Node String)
|
||||
exposedNodes =
|
||||
case context.exposedFromModule of
|
||||
Exposing.All _ ->
|
||||
List.filterMap declarationName nodes
|
||||
|
||||
Exposing.Explicit explicit ->
|
||||
List.map topLevelExposeName explicit
|
||||
|
||||
exposed : Set String
|
||||
exposed =
|
||||
Set.fromList (List.map Node.value exposedNodes)
|
||||
|
||||
( duplicateDocErrors, referencedElements ) =
|
||||
errorsForDuplicateDocs context.docsReferences
|
||||
in
|
||||
List.concat
|
||||
[ errorsForDocsForNonExposedElements exposed context.docsReferences
|
||||
, errorsForExposedElementsWithoutADocsReference referencedElements exposedNodes
|
||||
, List.concatMap errorsForDocsInDeclarationDoc nodes
|
||||
, duplicateDocErrors
|
||||
]
|
||||
|
||||
|
||||
errorsForDocsForNonExposedElements : Set String -> List (Node String) -> List (Rule.Error {})
|
||||
errorsForDocsForNonExposedElements exposed docsReferences =
|
||||
docsReferences
|
||||
|> List.filter (\(Node _ name) -> not (Set.member name exposed))
|
||||
|> List.map
|
||||
(\(Node range name) ->
|
||||
Rule.error
|
||||
{ message = "Found @docs reference for non-exposed `" ++ name ++ "`"
|
||||
, details =
|
||||
[ "I couldn't find this element among the module's exposed elements. Maybe you removed or renamed it recently."
|
||||
, "Please remove the @docs reference or update the reference to the new name."
|
||||
]
|
||||
}
|
||||
range
|
||||
)
|
||||
|
||||
|
||||
errorsForExposedElementsWithoutADocsReference : Set String -> List (Node String) -> List (Rule.Error {})
|
||||
errorsForExposedElementsWithoutADocsReference allDocsReferences exposedNodes =
|
||||
exposedNodes
|
||||
|> List.filter (\(Node _ name) -> not (Set.member name allDocsReferences))
|
||||
|> List.map
|
||||
(\(Node range name) ->
|
||||
Rule.error
|
||||
{ message = "Missing @docs reference for exposed `" ++ name ++ "`"
|
||||
, details =
|
||||
[ "There is no @docs reference for this element. Maybe you exposed or renamed it recently."
|
||||
, "Please add a @docs reference to it the module documentation (the one at the top of the module) like this:"
|
||||
, """{-|
|
||||
@docs """ ++ name ++ """
|
||||
-}"""
|
||||
]
|
||||
}
|
||||
range
|
||||
)
|
||||
|
||||
|
||||
errorsForDocsInDeclarationDoc : Node Declaration -> List (Rule.Error {})
|
||||
errorsForDocsInDeclarationDoc node =
|
||||
case docForDeclaration node of
|
||||
Just ( declarationType, Node docRange docContent ) ->
|
||||
indexedConcatMap
|
||||
(\lineNumber lineContent ->
|
||||
lineContent
|
||||
|> Parser.run (docsWithSpacesParser (lineNumber + docRange.start.row))
|
||||
|> Result.map
|
||||
(\range ->
|
||||
[ Rule.error
|
||||
{ message = "Found usage of @docs in a " ++ declarationType ++ " documentation"
|
||||
, details = [ "@docs can only be used in the module's documentation. You should remove this @docs and move it there." ]
|
||||
}
|
||||
range
|
||||
]
|
||||
)
|
||||
|> Result.withDefault []
|
||||
)
|
||||
(String.lines docContent)
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
|
||||
|
||||
docForDeclaration : Node Declaration -> Maybe ( String, Node String )
|
||||
docForDeclaration node =
|
||||
case Node.value node of
|
||||
Declaration.FunctionDeclaration function ->
|
||||
Maybe.map (Tuple.pair "function") function.documentation
|
||||
|
||||
Declaration.AliasDeclaration typeAlias ->
|
||||
Maybe.map (Tuple.pair "type") typeAlias.documentation
|
||||
|
||||
Declaration.CustomTypeDeclaration customType ->
|
||||
Maybe.map (Tuple.pair "type") customType.documentation
|
||||
|
||||
Declaration.PortDeclaration _ ->
|
||||
-- TODO Support port declaration in elm-syntax v8
|
||||
Nothing
|
||||
|
||||
Declaration.InfixDeclaration _ ->
|
||||
Nothing
|
||||
|
||||
Declaration.Destructuring _ _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
errorsForDuplicateDocs : List (Node String) -> ( List (Rule.Error {}), Set String )
|
||||
errorsForDuplicateDocs docsReferences =
|
||||
List.foldl
|
||||
(\(Node range name) ( errors, previouslyFoundNames ) ->
|
||||
case Dict.get name previouslyFoundNames of
|
||||
Just lineNumber ->
|
||||
( Rule.error
|
||||
{ message = "Found duplicate @docs reference for `element`"
|
||||
, details = [ "An element should only be referenced once, but I found a previous reference to it on line " ++ String.fromInt lineNumber ++ ". Please remove one of them." ]
|
||||
}
|
||||
range
|
||||
:: errors
|
||||
, previouslyFoundNames
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( errors, Dict.insert name range.start.row previouslyFoundNames )
|
||||
)
|
||||
( [], Dict.empty )
|
||||
docsReferences
|
||||
|> Tuple.mapSecond (Dict.keys >> Set.fromList)
|
||||
|
||||
|
||||
declarationName : Node Declaration -> Maybe (Node String)
|
||||
declarationName node =
|
||||
case Node.value node of
|
||||
Declaration.FunctionDeclaration function ->
|
||||
function.declaration |> Node.value |> .name |> Just
|
||||
|
||||
Declaration.AliasDeclaration typeAlias ->
|
||||
Just typeAlias.name
|
||||
|
||||
Declaration.CustomTypeDeclaration type_ ->
|
||||
Just type_.name
|
||||
|
||||
Declaration.PortDeclaration signature ->
|
||||
Just signature.name
|
||||
|
||||
Declaration.InfixDeclaration { operator } ->
|
||||
Just operator
|
||||
|
||||
Declaration.Destructuring _ _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
topLevelExposeName : Node Exposing.TopLevelExpose -> Node String
|
||||
topLevelExposeName (Node range topLevelExpose) =
|
||||
case topLevelExpose of
|
||||
Exposing.InfixExpose name ->
|
||||
Node range name
|
||||
|
||||
Exposing.FunctionExpose name ->
|
||||
Node range name
|
||||
|
||||
Exposing.TypeOrAliasExpose name ->
|
||||
Node range name
|
||||
|
||||
Exposing.TypeExpose { name } ->
|
||||
Node range name
|
||||
|
||||
|
||||
find : (a -> Bool) -> List a -> Maybe a
|
||||
find predicate list =
|
||||
case list of
|
||||
[] ->
|
||||
Nothing
|
||||
|
||||
first :: rest ->
|
||||
if predicate first then
|
||||
Just first
|
||||
|
||||
else
|
||||
find predicate rest
|
||||
|
||||
|
||||
indexedConcatMap : (Int -> a -> List b) -> List a -> List b
|
||||
indexedConcatMap function list =
|
||||
List.foldl
|
||||
(\a ( index, acc ) -> ( index + 1, List.append (function index a) acc ))
|
||||
( 0, [] )
|
||||
list
|
||||
|> Tuple.second
|
382
tests/Docs/ReviewAtDocsTest.elm
Normal file
382
tests/Docs/ReviewAtDocsTest.elm
Normal file
@ -0,0 +1,382 @@
|
||||
module Docs.ReviewAtDocsTest exposing (all)
|
||||
|
||||
import Docs.ReviewAtDocs exposing (rule)
|
||||
import Elm.Project
|
||||
import Json.Decode
|
||||
import Review.Project as Project exposing (Project)
|
||||
import Review.Test
|
||||
import Test exposing (Test, describe, test)
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Docs.ReviewAtDocs"
|
||||
[ test "should not report an error when all @docs are correct" <|
|
||||
\() ->
|
||||
"""module A exposing (D, T, a, b, c)
|
||||
|
||||
{-| Bla bla
|
||||
|
||||
@docs T, a, b
|
||||
@docs c, D
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error when an element has a @docs reference but function is not exposed" <|
|
||||
\() ->
|
||||
"""module A exposing (a, b)
|
||||
|
||||
{-| Bla bla
|
||||
@docs a, b, notExposed
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
b = 2
|
||||
notExposed = 3
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found @docs reference for non-exposed `notExposed`"
|
||||
, details =
|
||||
[ "I couldn't find this element among the module's exposed elements. Maybe you removed or renamed it recently."
|
||||
, "Please remove the @docs reference or update the reference to the new name."
|
||||
]
|
||||
, under = "notExposed"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 4, column = 13 }, end = { row = 4, column = 23 } }
|
||||
]
|
||||
, test "should report an error when an element has a @docs reference but type is not exposed" <|
|
||||
\() ->
|
||||
"""module A exposing (a, b)
|
||||
|
||||
{-| Bla bla
|
||||
@docs a, b, NotExposed
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
b = 2
|
||||
type NotExposed = NotExposed
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found @docs reference for non-exposed `NotExposed`"
|
||||
, details =
|
||||
[ "I couldn't find this element among the module's exposed elements. Maybe you removed or renamed it recently."
|
||||
, "Please remove the @docs reference or update the reference to the new name."
|
||||
]
|
||||
, under = "NotExposed"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 4, column = 13 }, end = { row = 4, column = 23 } }
|
||||
]
|
||||
, test "should not report an error when an element has a @docs reference and is exposed with exposing (..)" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
|
||||
{-| Bla bla
|
||||
@docs a, b, Exposed
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
b = 2
|
||||
type Exposed = Exposed
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error when an unknown element has a @docs reference, with exposing (..)" <|
|
||||
\() ->
|
||||
"""module A exposing (..)
|
||||
|
||||
{-| Bla bla
|
||||
@docs a, b, Exposed
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
b = 2
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found @docs reference for non-exposed `Exposed`"
|
||||
, details =
|
||||
[ "I couldn't find this element among the module's exposed elements. Maybe you removed or renamed it recently."
|
||||
, "Please remove the @docs reference or update the reference to the new name."
|
||||
]
|
||||
, under = "Exposed"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 4, column = 13 }, end = { row = 4, column = 20 } }
|
||||
]
|
||||
, test "should report an error when encountering @docs on the first line of the module documentation (without space)" <|
|
||||
\() ->
|
||||
"""module A exposing (a)
|
||||
|
||||
{-|@docs a
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found @docs on the first line"
|
||||
, details = [ "Using @docs on the first line will make for a broken documentation once published. Please move it to the beginning of the next line." ]
|
||||
, under = "@docs"
|
||||
}
|
||||
]
|
||||
, test "should report an error when encountering @docs on the first line of the module documentation (with space)" <|
|
||||
\() ->
|
||||
"""module A exposing (a)
|
||||
|
||||
{-| @docs a
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found @docs on the first line"
|
||||
, details = [ "Using @docs on the first line will make for a broken documentation once published. Please move it to the beginning of the next line." ]
|
||||
, under = "@docs"
|
||||
}
|
||||
]
|
||||
, test "should report an error when encountering indented @docs" <|
|
||||
\() ->
|
||||
"""module A exposing (a)
|
||||
|
||||
{-|
|
||||
@docs a
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found indented @docs"
|
||||
, details = [ "@docs need to be at the beginning of a line, otherwise they can lead to broken documentation once published. on the first line will make for a broken documentation once published. Please remove the leading spaces" ]
|
||||
, under = "@docs"
|
||||
}
|
||||
]
|
||||
, test "should report an error when an element is exposed but has no @docs reference" <|
|
||||
\() ->
|
||||
"""module A exposing (a, b, exposed)
|
||||
|
||||
{-| Bla bla
|
||||
@docs a, b
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
b = 2
|
||||
exposed = 3
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing @docs reference for exposed `exposed`"
|
||||
, details =
|
||||
[ "There is no @docs reference for this element. Maybe you exposed or renamed it recently."
|
||||
, "Please add a @docs reference to it the module documentation (the one at the top of the module) like this:"
|
||||
, """{-|
|
||||
@docs exposed
|
||||
-}"""
|
||||
]
|
||||
, under = "exposed"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 1, column = 26 }, end = { row = 1, column = 33 } }
|
||||
]
|
||||
, test "should not report errors when there is no @docs at all" <|
|
||||
\() ->
|
||||
"""module A exposing (a)
|
||||
|
||||
{-| Bla bla
|
||||
-}
|
||||
import B
|
||||
a = 1
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report errors for exposed modules of a package even if there are no @docs at all" <|
|
||||
\() ->
|
||||
"""module Exposed exposing (element)
|
||||
|
||||
{-| Bla bla
|
||||
-}
|
||||
import B
|
||||
element = 1
|
||||
"""
|
||||
|> Review.Test.runWithProjectData package rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Missing @docs reference for exposed `element`"
|
||||
, details =
|
||||
[ "There is no @docs reference for this element. Maybe you exposed or renamed it recently."
|
||||
, "Please add a @docs reference to it the module documentation (the one at the top of the module) like this:"
|
||||
, """{-|
|
||||
@docs element
|
||||
-}"""
|
||||
]
|
||||
, under = "element"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 1, column = 26 }, end = { row = 1, column = 33 } }
|
||||
]
|
||||
, test "should report errors for duplicate docs" <|
|
||||
\() ->
|
||||
"""module Exposed exposing (something, element)
|
||||
|
||||
{-|
|
||||
@docs element
|
||||
@docs something, element
|
||||
-}
|
||||
import B
|
||||
element = 1
|
||||
something = 2
|
||||
"""
|
||||
|> Review.Test.runWithProjectData package rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found duplicate @docs reference for `element`"
|
||||
, details = [ "An element should only be referenced once, but I found a previous reference to it on line 4. Please remove one of them." ]
|
||||
, under = "element"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 5, column = 18 }, end = { row = 5, column = 25 } }
|
||||
]
|
||||
, test "should report errors for usage of @docs in function documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (something, element)
|
||||
{-|
|
||||
@docs something, element
|
||||
-}
|
||||
import B
|
||||
{-|
|
||||
@docs something
|
||||
-}
|
||||
element = 1
|
||||
something = 2
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found usage of @docs in a function documentation"
|
||||
, details = [ "@docs can only be used in the module's documentation. You should remove this @docs and move it there." ]
|
||||
, under = "@docs"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 7, column = 1 }, end = { row = 7, column = 6 } }
|
||||
]
|
||||
, test "should report errors for usage of @docs in function documentation even if there are no @docs in the module documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (something, element)
|
||||
import B
|
||||
{-|
|
||||
@docs something
|
||||
-}
|
||||
element = 1
|
||||
something = 2
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found usage of @docs in a function documentation"
|
||||
, details = [ "@docs can only be used in the module's documentation. You should remove this @docs and move it there." ]
|
||||
, under = "@docs"
|
||||
}
|
||||
]
|
||||
, test "should report errors for usage of @docs in type alias documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (something, Element)
|
||||
{-|
|
||||
@docs something, Element
|
||||
-}
|
||||
import B
|
||||
{-|
|
||||
@docs something
|
||||
-}
|
||||
type alias Element = {}
|
||||
something = 2
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found usage of @docs in a type documentation"
|
||||
, details = [ "@docs can only be used in the module's documentation. You should remove this @docs and move it there." ]
|
||||
, under = "@docs"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 7, column = 1 }, end = { row = 7, column = 6 } }
|
||||
]
|
||||
, test "should report errors for usage of @docs in custom type documentation" <|
|
||||
\() ->
|
||||
"""module A exposing (something, Element)
|
||||
{-|
|
||||
@docs something, Element
|
||||
-}
|
||||
import B
|
||||
{-|
|
||||
Bla bla bla
|
||||
|
||||
@docs something
|
||||
-}
|
||||
type Element = Element
|
||||
something = 2
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectErrors
|
||||
[ Review.Test.error
|
||||
{ message = "Found usage of @docs in a type documentation"
|
||||
, details = [ "@docs can only be used in the module's documentation. You should remove this @docs and move it there." ]
|
||||
, under = "@docs"
|
||||
}
|
||||
|> Review.Test.atExactly { start = { row = 9, column = 1 }, end = { row = 9, column = 6 } }
|
||||
]
|
||||
, test "should not report mention of @docs after words" <|
|
||||
\() ->
|
||||
"""module A exposing (a)
|
||||
{-|
|
||||
@docs a
|
||||
-}
|
||||
import B
|
||||
|
||||
{-| Reports problems with the usages of `@docs`.
|
||||
-}
|
||||
a = 1
|
||||
"""
|
||||
|> Review.Test.run rule
|
||||
|> Review.Test.expectNoErrors
|
||||
]
|
||||
|
||||
|
||||
package : Project
|
||||
package =
|
||||
case Json.Decode.decodeString Elm.Project.decoder elmJson of
|
||||
Ok project ->
|
||||
Project.new
|
||||
|> Project.addElmJson
|
||||
{ path = "elm.json"
|
||||
, raw = elmJson
|
||||
, project = project
|
||||
}
|
||||
|
||||
Err err ->
|
||||
Debug.todo ("Invalid elm.json supplied to test: " ++ Debug.toString err)
|
||||
|
||||
|
||||
elmJson : String
|
||||
elmJson =
|
||||
"""{
|
||||
"type": "package",
|
||||
"name": "author/package",
|
||||
"summary": "Summary",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "1.0.0",
|
||||
"exposed-modules": [
|
||||
"Exposed"
|
||||
],
|
||||
"elm-version": "0.19.0 <= v < 0.20.0",
|
||||
"dependencies": {
|
||||
"elm/core": "1.0.0 <= v < 2.0.0"
|
||||
},
|
||||
"test-dependencies": {}
|
||||
}"""
|
821
tests/Docs/ReviewLinksAndSections.elm
Normal file
821
tests/Docs/ReviewLinksAndSections.elm
Normal file
@ -0,0 +1,821 @@
|
||||
module Docs.ReviewLinksAndSections exposing (rule)
|
||||
|
||||
{-|
|
||||
|
||||
@docs rule
|
||||
|
||||
-}
|
||||
|
||||
import Dict exposing (Dict)
|
||||
import Docs.Utils.Link as Link
|
||||
import Docs.Utils.Slug as Slug
|
||||
import Elm.Module
|
||||
import Elm.Package
|
||||
import Elm.Project
|
||||
import Elm.Syntax.Declaration as Declaration exposing (Declaration)
|
||||
import Elm.Syntax.Documentation exposing (Documentation)
|
||||
import Elm.Syntax.Exposing as Exposing
|
||||
import Elm.Syntax.Module as Module exposing (Module)
|
||||
import Elm.Syntax.ModuleName exposing (ModuleName)
|
||||
import Elm.Syntax.Node as Node exposing (Node(..))
|
||||
import Elm.Syntax.Range exposing (Range)
|
||||
import Elm.Version
|
||||
import Regex exposing (Regex)
|
||||
import Review.Rule as Rule exposing (Rule)
|
||||
import Set exposing (Set)
|
||||
|
||||
|
||||
{-| Reports problems with links and sections in Elm projects.
|
||||
|
||||
config =
|
||||
[ Docs.ReviewLinksAndSections.rule
|
||||
]
|
||||
|
||||
|
||||
## Fail
|
||||
|
||||
Links to missing modules or sections are reported.
|
||||
|
||||
{-| Link to [missing module](Unknown-Module).
|
||||
-}
|
||||
a =
|
||||
1
|
||||
|
||||
{-| Link to [missing section](#unknown).
|
||||
-}
|
||||
a =
|
||||
1
|
||||
|
||||
In packages, links that would appear in the public documentation and that link to sections not part of the public documentation are reported.
|
||||
|
||||
module Exposed exposing (a)
|
||||
|
||||
import Internal
|
||||
|
||||
{-| Link to [internal details](Internal#section).
|
||||
-}
|
||||
a =
|
||||
1
|
||||
|
||||
Sections that would have the same generated id are reported,
|
||||
so that links don't inadvertently point to the wrong location.
|
||||
|
||||
module A exposing (element, section)
|
||||
|
||||
{-|
|
||||
|
||||
|
||||
# Section
|
||||
|
||||
The above conflicts with the id generated
|
||||
for the `section` value.
|
||||
|
||||
-}
|
||||
|
||||
element =
|
||||
1
|
||||
|
||||
section =
|
||||
1
|
||||
|
||||
|
||||
## Success
|
||||
|
||||
module Exposed exposing (a, b)
|
||||
|
||||
import Internal
|
||||
|
||||
{-| Link to [exposed b](#b).
|
||||
-}
|
||||
a =
|
||||
1
|
||||
|
||||
b =
|
||||
2
|
||||
|
||||
|
||||
## When (not) to enable this rule
|
||||
|
||||
For packages, this rule will be useful to prevent having dead links in the package documentation.
|
||||
|
||||
For applications, this rule will be useful if you have the habit of writing documentation the way you do in Elm packages,
|
||||
and want to prevent it from going out of date.
|
||||
|
||||
This rule will not be useful if your project is an application and no-one in the team has the habit of writing
|
||||
package-like documentation.
|
||||
|
||||
|
||||
## Try it out
|
||||
|
||||
You can try this rule out by running the following command:
|
||||
|
||||
```bash
|
||||
elm-review --template jfmengels/elm-review-documentation/example --rules Docs.ReviewLinksAndSections
|
||||
```
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to @lue-bird for helping out with this rule.
|
||||
|
||||
-}
|
||||
rule : Rule
|
||||
rule =
|
||||
Rule.newProjectRuleSchema "Docs.ReviewLinksAndSections" initialProjectContext
|
||||
|> Rule.withElmJsonProjectVisitor elmJsonVisitor
|
||||
|> Rule.withReadmeProjectVisitor readmeVisitor
|
||||
|> Rule.withModuleVisitor moduleVisitor
|
||||
|> Rule.withModuleContextUsingContextCreator
|
||||
{ fromProjectToModule = fromProjectToModule
|
||||
, fromModuleToProject = fromModuleToProject
|
||||
, foldProjectContexts = foldProjectContexts
|
||||
}
|
||||
|> Rule.withFinalProjectEvaluation finalEvaluation
|
||||
|> Rule.fromProjectRuleSchema
|
||||
|
||||
|
||||
type alias ProjectContext =
|
||||
{ fileLinksAndSections : List FileLinksAndSections
|
||||
, packageNameAndVersion : Maybe { name : String, version : String }
|
||||
, exposedModules : Set ModuleName
|
||||
}
|
||||
|
||||
|
||||
initialProjectContext : ProjectContext
|
||||
initialProjectContext =
|
||||
{ fileLinksAndSections = []
|
||||
, packageNameAndVersion = Nothing
|
||||
, exposedModules = Set.empty
|
||||
}
|
||||
|
||||
|
||||
type alias FileLinksAndSections =
|
||||
{ moduleName : ModuleName
|
||||
, fileKey : FileKey
|
||||
, sections : List Section
|
||||
, links : List MaybeExposedLink
|
||||
}
|
||||
|
||||
|
||||
type FileKey
|
||||
= ModuleKey Rule.ModuleKey
|
||||
| ReadmeKey Rule.ReadmeKey
|
||||
|
||||
|
||||
type alias ModuleContext =
|
||||
{ isModuleExposed : Bool
|
||||
, exposedElements : Set String
|
||||
, moduleName : ModuleName
|
||||
, commentSections : List SectionWithRange
|
||||
, sections : List Section
|
||||
, links : List MaybeExposedLink
|
||||
}
|
||||
|
||||
|
||||
type alias Section =
|
||||
{ slug : String
|
||||
, isExposed : Bool
|
||||
}
|
||||
|
||||
|
||||
type MaybeExposedLink
|
||||
= MaybeExposedLink MaybeExposedLinkData
|
||||
|
||||
|
||||
type alias MaybeExposedLinkData =
|
||||
{ link : Link.Link
|
||||
, linkRange : Range
|
||||
, isExposed : Bool
|
||||
}
|
||||
|
||||
|
||||
fromProjectToModule : Rule.ContextCreator ProjectContext ModuleContext
|
||||
fromProjectToModule =
|
||||
Rule.initContextCreator
|
||||
(\metadata projectContext ->
|
||||
let
|
||||
moduleName : ModuleName
|
||||
moduleName =
|
||||
Rule.moduleNameFromMetadata metadata
|
||||
in
|
||||
{ isModuleExposed = Set.member moduleName projectContext.exposedModules
|
||||
, exposedElements = Set.empty
|
||||
, moduleName = moduleName
|
||||
, commentSections = []
|
||||
, sections = []
|
||||
, links = []
|
||||
}
|
||||
)
|
||||
|> Rule.withMetadata
|
||||
|
||||
|
||||
fromModuleToProject : Rule.ContextCreator ModuleContext ProjectContext
|
||||
fromModuleToProject =
|
||||
Rule.initContextCreator
|
||||
(\moduleKey moduleContext ->
|
||||
{ fileLinksAndSections =
|
||||
[ { moduleName = moduleContext.moduleName
|
||||
, fileKey = ModuleKey moduleKey
|
||||
, sections = moduleContext.sections
|
||||
, links = moduleContext.links
|
||||
}
|
||||
]
|
||||
, packageNameAndVersion = Nothing
|
||||
, exposedModules = Set.empty
|
||||
}
|
||||
)
|
||||
|> Rule.withModuleKey
|
||||
|
||||
|
||||
foldProjectContexts : ProjectContext -> ProjectContext -> ProjectContext
|
||||
foldProjectContexts newContext previousContext =
|
||||
{ fileLinksAndSections = List.append newContext.fileLinksAndSections previousContext.fileLinksAndSections
|
||||
, packageNameAndVersion = previousContext.packageNameAndVersion
|
||||
, exposedModules = previousContext.exposedModules
|
||||
}
|
||||
|
||||
|
||||
moduleVisitor : Rule.ModuleRuleSchema schemaState ModuleContext -> Rule.ModuleRuleSchema { schemaState | hasAtLeastOneVisitor : () } ModuleContext
|
||||
moduleVisitor schema =
|
||||
schema
|
||||
|> Rule.withModuleDefinitionVisitor moduleDefinitionVisitor
|
||||
|> Rule.withCommentsVisitor commentsVisitor
|
||||
|> Rule.withDeclarationListVisitor declarationListVisitor
|
||||
|
||||
|
||||
|
||||
-- ELM.JSON VISITOR
|
||||
|
||||
|
||||
elmJsonVisitor : Maybe { a | project : Elm.Project.Project } -> ProjectContext -> ( List nothing, ProjectContext )
|
||||
elmJsonVisitor maybeElmJson projectContext =
|
||||
case Maybe.map .project maybeElmJson of
|
||||
Just (Elm.Project.Package { name, version, exposed }) ->
|
||||
( []
|
||||
, { projectContext
|
||||
| packageNameAndVersion = Just { name = Elm.Package.toString name, version = Elm.Version.toString version }
|
||||
, exposedModules = listExposedModules exposed
|
||||
}
|
||||
)
|
||||
|
||||
_ ->
|
||||
( [], projectContext )
|
||||
|
||||
|
||||
listExposedModules : Elm.Project.Exposed -> Set ModuleName
|
||||
listExposedModules exposed =
|
||||
let
|
||||
exposedModules : List ModuleName
|
||||
exposedModules =
|
||||
exposedModulesFromPackageAsList exposed
|
||||
|> List.map (Elm.Module.toString >> String.split ".")
|
||||
in
|
||||
Set.fromList ([] :: exposedModules)
|
||||
|
||||
|
||||
exposedModulesFromPackageAsList : Elm.Project.Exposed -> List Elm.Module.Name
|
||||
exposedModulesFromPackageAsList exposed =
|
||||
case exposed of
|
||||
Elm.Project.ExposedList list ->
|
||||
list
|
||||
|
||||
Elm.Project.ExposedDict list ->
|
||||
List.concatMap Tuple.second list
|
||||
|
||||
|
||||
|
||||
-- README VISITOR
|
||||
|
||||
|
||||
readmeVisitor : Maybe { readmeKey : Rule.ReadmeKey, content : String } -> ProjectContext -> ( List (Rule.Error { useErrorForModule : () }), ProjectContext )
|
||||
readmeVisitor maybeReadmeInfo projectContext =
|
||||
case maybeReadmeInfo of
|
||||
Just { readmeKey, content } ->
|
||||
let
|
||||
isReadmeExposed : Bool
|
||||
isReadmeExposed =
|
||||
Set.member [] projectContext.exposedModules
|
||||
|
||||
sectionsAndLinks : { titleSections : List SectionWithRange, links : List MaybeExposedLink }
|
||||
sectionsAndLinks =
|
||||
findSectionsAndLinks
|
||||
[]
|
||||
isReadmeExposed
|
||||
{ content = content
|
||||
, startRow = 1
|
||||
}
|
||||
in
|
||||
( duplicateSectionErrors Set.empty sectionsAndLinks.titleSections
|
||||
|> List.map (Rule.errorForReadme readmeKey duplicateSectionErrorDetails)
|
||||
, { fileLinksAndSections =
|
||||
{ moduleName = []
|
||||
, fileKey = ReadmeKey readmeKey
|
||||
, sections = List.map removeRangeFromSection sectionsAndLinks.titleSections
|
||||
, links = sectionsAndLinks.links
|
||||
}
|
||||
:: projectContext.fileLinksAndSections
|
||||
, packageNameAndVersion = projectContext.packageNameAndVersion
|
||||
, exposedModules = projectContext.exposedModules
|
||||
}
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( [], projectContext )
|
||||
|
||||
|
||||
|
||||
-- MODULE DEFINITION VISITOR
|
||||
|
||||
|
||||
moduleDefinitionVisitor : Node Module -> ModuleContext -> ( List nothing, ModuleContext )
|
||||
moduleDefinitionVisitor node context =
|
||||
case Module.exposingList (Node.value node) of
|
||||
Exposing.All _ ->
|
||||
-- We'll keep `exposedElements` empty, which will make `declarationListVisitor` fill it with the known
|
||||
-- declarations.
|
||||
( [], context )
|
||||
|
||||
Exposing.Explicit exposed ->
|
||||
( [], { context | exposedElements = Set.fromList (List.map exposedName exposed) } )
|
||||
|
||||
|
||||
exposedName : Node Exposing.TopLevelExpose -> String
|
||||
exposedName node =
|
||||
case Node.value node of
|
||||
Exposing.InfixExpose string ->
|
||||
string
|
||||
|
||||
Exposing.FunctionExpose string ->
|
||||
string
|
||||
|
||||
Exposing.TypeOrAliasExpose string ->
|
||||
string
|
||||
|
||||
Exposing.TypeExpose exposedType ->
|
||||
exposedType.name
|
||||
|
||||
|
||||
|
||||
-- COMMENTS VISITOR
|
||||
|
||||
|
||||
commentsVisitor : List (Node String) -> ModuleContext -> ( List nothing, ModuleContext )
|
||||
commentsVisitor comments context =
|
||||
let
|
||||
docs : List (Node String)
|
||||
docs =
|
||||
List.filter (Node.value >> String.startsWith "{-|") comments
|
||||
|
||||
sectionsAndLinks : List { titleSections : List SectionWithRange, links : List MaybeExposedLink }
|
||||
sectionsAndLinks =
|
||||
List.map
|
||||
(\doc ->
|
||||
findSectionsAndLinks
|
||||
context.moduleName
|
||||
context.isModuleExposed
|
||||
{ content = Node.value doc, startRow = (Node.range doc).start.row }
|
||||
)
|
||||
docs
|
||||
in
|
||||
( []
|
||||
, { isModuleExposed = context.isModuleExposed
|
||||
, exposedElements = context.exposedElements
|
||||
, moduleName = context.moduleName
|
||||
, commentSections = List.concatMap .titleSections sectionsAndLinks
|
||||
, sections =
|
||||
List.append
|
||||
(List.concatMap (.titleSections >> List.map removeRangeFromSection) sectionsAndLinks)
|
||||
context.sections
|
||||
, links = List.append (List.concatMap .links sectionsAndLinks) context.links
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
-- DECLARATION VISITOR
|
||||
|
||||
|
||||
declarationListVisitor : List (Node Declaration) -> ModuleContext -> ( List (Rule.Error {}), ModuleContext )
|
||||
declarationListVisitor declarations context =
|
||||
let
|
||||
exposedElements : Set String
|
||||
exposedElements =
|
||||
if Set.isEmpty context.exposedElements then
|
||||
Set.fromList (List.filterMap nameOfDeclaration declarations)
|
||||
|
||||
else
|
||||
context.exposedElements
|
||||
|
||||
knownSections : List { slug : String, isExposed : Bool }
|
||||
knownSections =
|
||||
List.append
|
||||
(List.map (\slug -> { slug = slug, isExposed = True }) (Set.toList exposedElements))
|
||||
context.sections
|
||||
|
||||
sectionsAndLinks : List { titleSections : List SectionWithRange, links : List MaybeExposedLink }
|
||||
sectionsAndLinks =
|
||||
List.map
|
||||
(findSectionsAndLinksForDeclaration
|
||||
context.moduleName
|
||||
(if context.isModuleExposed then
|
||||
exposedElements
|
||||
|
||||
else
|
||||
Set.empty
|
||||
)
|
||||
)
|
||||
declarations
|
||||
|
||||
titleSections : List SectionWithRange
|
||||
titleSections =
|
||||
List.concatMap .titleSections sectionsAndLinks
|
||||
in
|
||||
( duplicateSectionErrors exposedElements (List.append titleSections context.commentSections)
|
||||
|> List.map (Rule.error duplicateSectionErrorDetails)
|
||||
, { isModuleExposed = context.isModuleExposed
|
||||
, exposedElements = exposedElements
|
||||
, moduleName = context.moduleName
|
||||
, commentSections = context.commentSections
|
||||
, sections = List.append (List.map removeRangeFromSection titleSections) knownSections
|
||||
, links = List.append (List.concatMap .links sectionsAndLinks) context.links
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
duplicateSectionErrors : Set String -> List SectionWithRange -> List Range
|
||||
duplicateSectionErrors exposedElements sections =
|
||||
List.foldl
|
||||
(\{ slug, range } { errors, knownSections } ->
|
||||
if Set.member slug knownSections then
|
||||
{ errors = range :: errors
|
||||
, knownSections = knownSections
|
||||
}
|
||||
|
||||
else
|
||||
{ errors = errors
|
||||
, knownSections = Set.insert slug knownSections
|
||||
}
|
||||
)
|
||||
{ errors = [], knownSections = exposedElements }
|
||||
sections
|
||||
|> .errors
|
||||
|
||||
|
||||
extractSlugsFromHeadings : { content : String, startRow : Int } -> List (Node String)
|
||||
extractSlugsFromHeadings doc =
|
||||
doc.content
|
||||
|> String.lines
|
||||
|> List.indexedMap
|
||||
(\lineNumber line ->
|
||||
Regex.find specialsToHash line
|
||||
|> List.concatMap .submatches
|
||||
|> List.filterMap identity
|
||||
|> List.map
|
||||
(\slug ->
|
||||
Node
|
||||
{ start = { row = lineNumber + doc.startRow, column = 1 }
|
||||
, end = { row = lineNumber + doc.startRow, column = String.length line + 1 }
|
||||
}
|
||||
(Slug.toSlug slug)
|
||||
)
|
||||
)
|
||||
|> List.concat
|
||||
|
||||
|
||||
specialsToHash : Regex
|
||||
specialsToHash =
|
||||
"^#{1,6}\\s+(.*)$"
|
||||
|> Regex.fromString
|
||||
|> Maybe.withDefault Regex.never
|
||||
|
||||
|
||||
nameOfDeclaration : Node Declaration -> Maybe String
|
||||
nameOfDeclaration node =
|
||||
case Node.value node of
|
||||
Declaration.FunctionDeclaration { declaration } ->
|
||||
declaration
|
||||
|> Node.value
|
||||
|> .name
|
||||
|> Node.value
|
||||
|> Just
|
||||
|
||||
Declaration.AliasDeclaration { name } ->
|
||||
Just (Node.value name)
|
||||
|
||||
Declaration.CustomTypeDeclaration { name } ->
|
||||
Just (Node.value name)
|
||||
|
||||
Declaration.PortDeclaration { name } ->
|
||||
Just (Node.value name)
|
||||
|
||||
Declaration.InfixDeclaration { operator } ->
|
||||
Just (Node.value operator)
|
||||
|
||||
Declaration.Destructuring _ _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
docOfDeclaration : Declaration -> Maybe (Node Documentation)
|
||||
docOfDeclaration declaration =
|
||||
case declaration of
|
||||
Declaration.FunctionDeclaration { documentation } ->
|
||||
documentation
|
||||
|
||||
Declaration.AliasDeclaration { documentation } ->
|
||||
documentation
|
||||
|
||||
Declaration.CustomTypeDeclaration { documentation } ->
|
||||
documentation
|
||||
|
||||
Declaration.PortDeclaration _ ->
|
||||
Nothing
|
||||
|
||||
Declaration.InfixDeclaration _ ->
|
||||
Nothing
|
||||
|
||||
Declaration.Destructuring _ _ ->
|
||||
Nothing
|
||||
|
||||
|
||||
findSectionsAndLinksForDeclaration : ModuleName -> Set String -> Node Declaration -> { titleSections : List SectionWithRange, links : List MaybeExposedLink }
|
||||
findSectionsAndLinksForDeclaration currentModuleName exposedElements declaration =
|
||||
case docOfDeclaration (Node.value declaration) of
|
||||
Just doc ->
|
||||
let
|
||||
name : String
|
||||
name =
|
||||
nameOfDeclaration declaration
|
||||
|> Maybe.withDefault ""
|
||||
|
||||
isExposed : Bool
|
||||
isExposed =
|
||||
Set.member name exposedElements
|
||||
in
|
||||
findSectionsAndLinks
|
||||
currentModuleName
|
||||
isExposed
|
||||
{ content = Node.value doc, startRow = (Node.range doc).start.row }
|
||||
|
||||
Nothing ->
|
||||
{ titleSections = [], links = [] }
|
||||
|
||||
|
||||
type alias SectionWithRange =
|
||||
{ slug : String
|
||||
, range : Range
|
||||
, isExposed : Bool
|
||||
}
|
||||
|
||||
|
||||
removeRangeFromSection : SectionWithRange -> Section
|
||||
removeRangeFromSection { slug, isExposed } =
|
||||
{ slug = slug
|
||||
, isExposed = isExposed
|
||||
}
|
||||
|
||||
|
||||
findSectionsAndLinks : ModuleName -> Bool -> { content : String, startRow : Int } -> { titleSections : List SectionWithRange, links : List MaybeExposedLink }
|
||||
findSectionsAndLinks currentModuleName isExposed doc =
|
||||
let
|
||||
titleSections : List SectionWithRange
|
||||
titleSections =
|
||||
extractSlugsFromHeadings doc
|
||||
|> List.map
|
||||
(\slug ->
|
||||
{ slug = Node.value slug
|
||||
, range = Node.range slug
|
||||
, isExposed = isExposed
|
||||
}
|
||||
)
|
||||
|
||||
links : List MaybeExposedLink
|
||||
links =
|
||||
Link.findLinks (doc.startRow - 1) currentModuleName doc.content
|
||||
|> List.map
|
||||
(\link ->
|
||||
MaybeExposedLink
|
||||
{ link = Node.value link
|
||||
, linkRange = Node.range link
|
||||
, isExposed = isExposed
|
||||
}
|
||||
)
|
||||
in
|
||||
{ titleSections = titleSections
|
||||
, links = links
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- FINAL EVALUATION
|
||||
|
||||
|
||||
finalEvaluation : ProjectContext -> List (Rule.Error { useErrorForModule : () })
|
||||
finalEvaluation projectContext =
|
||||
let
|
||||
sectionsPerModule : Dict ModuleName (List Section)
|
||||
sectionsPerModule =
|
||||
projectContext.fileLinksAndSections
|
||||
|> List.map (\module_ -> ( module_.moduleName, module_.sections ))
|
||||
|> Dict.fromList
|
||||
in
|
||||
List.concatMap (errorsForFile projectContext sectionsPerModule) projectContext.fileLinksAndSections
|
||||
|
||||
|
||||
errorsForFile : ProjectContext -> Dict ModuleName (List Section) -> FileLinksAndSections -> List (Rule.Error scope)
|
||||
errorsForFile projectContext sectionsPerModule fileLinksAndSections =
|
||||
List.filterMap
|
||||
(errorForFile projectContext sectionsPerModule fileLinksAndSections)
|
||||
fileLinksAndSections.links
|
||||
|
||||
|
||||
errorForFile : ProjectContext -> Dict ModuleName (List Section) -> FileLinksAndSections -> MaybeExposedLink -> Maybe (Rule.Error scope)
|
||||
errorForFile projectContext sectionsPerModule fileLinksAndSections (MaybeExposedLink maybeExposedLink) =
|
||||
case maybeExposedLink.link.file of
|
||||
Link.ModuleTarget moduleName ->
|
||||
reportErrorForModule projectContext sectionsPerModule fileLinksAndSections maybeExposedLink moduleName
|
||||
|
||||
Link.ReadmeTarget ->
|
||||
reportErrorForReadme sectionsPerModule fileLinksAndSections.fileKey maybeExposedLink
|
||||
|
||||
Link.PackagesTarget packageTarget ->
|
||||
reportErrorsForPackagesTarget projectContext sectionsPerModule fileLinksAndSections maybeExposedLink packageTarget
|
||||
|
||||
Link.External target ->
|
||||
reportErrorsForExternalTarget (projectContext.packageNameAndVersion == Nothing) fileLinksAndSections.fileKey maybeExposedLink.linkRange target
|
||||
|
||||
|
||||
reportErrorsForPackagesTarget : ProjectContext -> Dict ModuleName (List Section) -> FileLinksAndSections -> MaybeExposedLinkData -> { name : String, version : String, subTarget : Link.SubTarget } -> Maybe (Rule.Error scope)
|
||||
reportErrorsForPackagesTarget projectContext sectionsPerModule fileLinksAndSections maybeExposedLink { name, version, subTarget } =
|
||||
case projectContext.packageNameAndVersion of
|
||||
Just currentPackage ->
|
||||
if name == currentPackage.name && (version == "latest" || version == currentPackage.version) then
|
||||
reportErrorForCurrentPackageSubTarget projectContext sectionsPerModule fileLinksAndSections maybeExposedLink subTarget
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
Nothing ->
|
||||
Nothing
|
||||
|
||||
|
||||
reportErrorForCurrentPackageSubTarget : ProjectContext -> Dict ModuleName (List Section) -> FileLinksAndSections -> MaybeExposedLinkData -> Link.SubTarget -> Maybe (Rule.Error scope)
|
||||
reportErrorForCurrentPackageSubTarget projectContext sectionsPerModule fileLinksAndSections maybeExposedLink subTarget =
|
||||
case subTarget of
|
||||
Link.ModuleSubTarget moduleName ->
|
||||
reportErrorForModule projectContext sectionsPerModule fileLinksAndSections maybeExposedLink moduleName
|
||||
|
||||
Link.ReadmeSubTarget ->
|
||||
reportErrorForReadme sectionsPerModule fileLinksAndSections.fileKey maybeExposedLink
|
||||
|
||||
|
||||
reportErrorForModule : ProjectContext -> Dict ModuleName (List Section) -> FileLinksAndSections -> MaybeExposedLinkData -> ModuleName -> Maybe (Rule.Error scope)
|
||||
reportErrorForModule projectContext sectionsPerModule fileLinksAndSections maybeExposedLink moduleName =
|
||||
case Dict.get moduleName sectionsPerModule of
|
||||
Just existingSections ->
|
||||
if Set.member fileLinksAndSections.moduleName projectContext.exposedModules && not (Set.member moduleName projectContext.exposedModules) then
|
||||
Just (reportLinkToNonExposedModule fileLinksAndSections.fileKey maybeExposedLink.linkRange)
|
||||
|
||||
else
|
||||
reportIfMissingSection fileLinksAndSections.fileKey existingSections maybeExposedLink
|
||||
|
||||
Nothing ->
|
||||
Just (reportUnknownModule fileLinksAndSections.fileKey moduleName maybeExposedLink.linkRange)
|
||||
|
||||
|
||||
reportErrorForReadme : Dict (List comparable) (List Section) -> FileKey -> MaybeExposedLinkData -> Maybe (Rule.Error scope)
|
||||
reportErrorForReadme sectionsPerModule fileKey maybeExposedLink =
|
||||
case Dict.get [] sectionsPerModule of
|
||||
Just existingSections ->
|
||||
reportIfMissingSection fileKey existingSections maybeExposedLink
|
||||
|
||||
Nothing ->
|
||||
Just (reportLinkToMissingReadme fileKey maybeExposedLink.linkRange)
|
||||
|
||||
|
||||
reportErrorsForExternalTarget : Bool -> FileKey -> Range -> String -> Maybe (Rule.Error scope)
|
||||
reportErrorsForExternalTarget isApplication fileKey linkRange target =
|
||||
if isApplication || String.contains "://" target then
|
||||
Nothing
|
||||
|
||||
else
|
||||
Just (reportLinkToExternalResourceWithoutProtocol fileKey linkRange)
|
||||
|
||||
|
||||
reportIfMissingSection : FileKey -> List Section -> MaybeExposedLinkData -> Maybe (Rule.Error scope)
|
||||
reportIfMissingSection fileKey existingSectionsForTargetFile { isExposed, linkRange, link } =
|
||||
case link.slug of
|
||||
Just "" ->
|
||||
Just (reportLinkWithEmptySlug fileKey linkRange)
|
||||
|
||||
Just slug ->
|
||||
case find (\section -> section.slug == slug) existingSectionsForTargetFile of
|
||||
Just section ->
|
||||
if isExposed && not section.isExposed then
|
||||
Just (reportLinkToNonExposedSection fileKey linkRange)
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
Nothing ->
|
||||
Just (reportLink fileKey linkRange)
|
||||
|
||||
Nothing ->
|
||||
Nothing
|
||||
|
||||
|
||||
reportLink : FileKey -> Range -> Rule.Error scope
|
||||
reportLink fileKey range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link points to a non-existing section or element"
|
||||
, details = [ "This is a dead link." ]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
reportLinkToNonExposedModule : FileKey -> Range -> Rule.Error scope
|
||||
reportLinkToNonExposedModule fileKey range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link in public documentation points to non-exposed module"
|
||||
, details = [ "Users will not be able to follow the link." ]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
reportLinkToNonExposedSection : FileKey -> Range -> Rule.Error scope
|
||||
reportLinkToNonExposedSection fileKey range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link in public documentation points to non-exposed section"
|
||||
, details = [ "Users will not be able to follow the link." ]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
reportLinkWithEmptySlug : FileKey -> Range -> Rule.Error scope
|
||||
reportLinkWithEmptySlug fileKey range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link to empty section is unnecessary"
|
||||
, details = [ "Links to # not followed by an id don't provide any value to the user. I suggest to either strip the # or remove the link." ]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
reportUnknownModule : FileKey -> ModuleName -> Range -> Rule.Error scope
|
||||
reportUnknownModule fileKey moduleName range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link points to non-existing module " ++ String.join "." moduleName
|
||||
, details = [ "This is a dead link." ]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
reportLinkToMissingReadme : FileKey -> Range -> Rule.Error scope
|
||||
reportLinkToMissingReadme fileKey range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link points to missing README"
|
||||
, details = [ "elm-review only looks for a 'README.md' located next to your 'elm.json'. Maybe it's positioned elsewhere or named differently?" ]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
reportLinkToExternalResourceWithoutProtocol : FileKey -> Range -> Rule.Error scope
|
||||
reportLinkToExternalResourceWithoutProtocol fileKey range =
|
||||
reportForFile fileKey
|
||||
{ message = "Link to unknown resource without a protocol"
|
||||
, details =
|
||||
[ "I have trouble figuring out what kind of resource is linked here."
|
||||
, "If it should link to a module, then they should be in the form 'Some-Module-Name'."
|
||||
, "If it's a link to an external resource, they should start with a protocol, like `https://www.fruits.com`, otherwise the link will point to an unknown resource on package.elm-lang.org."
|
||||
]
|
||||
}
|
||||
range
|
||||
|
||||
|
||||
duplicateSectionErrorDetails : { message : String, details : List String }
|
||||
duplicateSectionErrorDetails =
|
||||
{ message = "Duplicate section"
|
||||
, details = [ "There are multiple sections that will result in the same id, meaning that links may point towards the wrong element." ]
|
||||
}
|
||||
|
||||
|
||||
reportForFile : FileKey -> { message : String, details : List String } -> Range -> Rule.Error scope
|
||||
reportForFile fileKey =
|
||||
case fileKey of
|
||||
ModuleKey moduleKey ->
|
||||
Rule.errorForModule moduleKey
|
||||
|
||||
ReadmeKey readmeKey ->
|
||||
Rule.errorForReadme readmeKey
|
||||
|
||||
|
||||
find : (a -> Bool) -> List a -> Maybe a
|
||||
find predicate list =
|
||||
case list of
|
||||
[] ->
|
||||
Nothing
|
||||
|
||||
first :: rest ->
|
||||
if predicate first then
|
||||
Just first
|
||||
|
||||
else
|
||||
find predicate rest
|
1133
tests/Docs/ReviewLinksAndSectionsTest.elm
Normal file
1133
tests/Docs/ReviewLinksAndSectionsTest.elm
Normal file
File diff suppressed because it is too large
Load Diff
183
tests/Docs/UpToDateReadmeLinks.elm
Normal file
183
tests/Docs/UpToDateReadmeLinks.elm
Normal file
@ -0,0 +1,183 @@
|
||||
module Docs.UpToDateReadmeLinks exposing (rule)
|
||||
|
||||
{-|
|
||||
|
||||
@docs rule
|
||||
|
||||
-}
|
||||
|
||||
import Docs.Utils.Link as Link exposing (Link)
|
||||
import Elm.Package
|
||||
import Elm.Project
|
||||
import Elm.Syntax.Node exposing (Node(..))
|
||||
import Elm.Version
|
||||
import Review.Fix as Fix
|
||||
import Review.Rule as Rule exposing (Error, Rule)
|
||||
|
||||
|
||||
{-| Reports links in the `README.md` that point to this project's package documentation on <https://package.elm-lang.org/>,
|
||||
where the version is set to `latest` or a different version than the current version of the package.
|
||||
|
||||
🔧 Running with `--fix` will automatically remove all the reported errors.
|
||||
|
||||
config =
|
||||
[ Docs.UpToDateReadmeLinks.rule
|
||||
]
|
||||
|
||||
The problem with linking to `latest` is that if you release a new version later,
|
||||
the users who read the README for the older version will be directed to a version
|
||||
where the module/function/section you pointed to may not exist anymore.
|
||||
|
||||
This rule ensures that you always use the correct version in all of your releases,
|
||||
and that you do not forget to update the links.
|
||||
|
||||
This rule provides automatic fixes, so you won't to do the tedious job of updating
|
||||
the links yourself.
|
||||
|
||||
**NOTE**: Just make sure to run tests between bumping the version of the package
|
||||
and publishing the package. Otherwise the link for a given version could link to a previous one.
|
||||
|
||||
**NOTE**: A similar rule would be useful for links inside the modules. I'll be working on that too!
|
||||
|
||||
|
||||
## Try it out
|
||||
|
||||
You can try this rule out by running the following command:
|
||||
|
||||
```bash
|
||||
elm-review --template jfmengels/elm-review-documentation/example --rules Docs.UpToDateReadmeLinks
|
||||
```
|
||||
|
||||
-}
|
||||
rule : Rule
|
||||
rule =
|
||||
Rule.newProjectRuleSchema "Docs.UpToDateReadmeLinks" initialProjectContext
|
||||
|> Rule.withElmJsonProjectVisitor elmJsonVisitor
|
||||
|> Rule.withReadmeProjectVisitor readmeVisitor
|
||||
|> Rule.fromProjectRuleSchema
|
||||
|
||||
|
||||
type alias ProjectContext =
|
||||
Maybe
|
||||
{ projectName : String
|
||||
, version : String
|
||||
}
|
||||
|
||||
|
||||
initialProjectContext : ProjectContext
|
||||
initialProjectContext =
|
||||
Nothing
|
||||
|
||||
|
||||
|
||||
-- elm.json VISITOR
|
||||
|
||||
|
||||
elmJsonVisitor : Maybe { a | project : Elm.Project.Project } -> ProjectContext -> ( List nothing, ProjectContext )
|
||||
elmJsonVisitor maybeProject _ =
|
||||
case maybeProject |> Maybe.map .project of
|
||||
Just (Elm.Project.Package pkg) ->
|
||||
( []
|
||||
, Just
|
||||
{ projectName = Elm.Package.toString pkg.name
|
||||
, version = Elm.Version.toString pkg.version
|
||||
}
|
||||
)
|
||||
|
||||
_ ->
|
||||
( [], Nothing )
|
||||
|
||||
|
||||
|
||||
-- README VISITOR
|
||||
|
||||
|
||||
readmeVisitor : Maybe { readmeKey : Rule.ReadmeKey, content : String } -> ProjectContext -> ( List (Error scope), ProjectContext )
|
||||
readmeVisitor maybeReadme maybeContext =
|
||||
case ( maybeReadme, maybeContext ) of
|
||||
( Just { readmeKey, content }, Just context ) ->
|
||||
( reportErrorsForReadme context readmeKey content, maybeContext )
|
||||
|
||||
_ ->
|
||||
( [], maybeContext )
|
||||
|
||||
|
||||
reportErrorsForReadme : { projectName : String, version : String } -> Rule.ReadmeKey -> String -> List (Error scope)
|
||||
reportErrorsForReadme context readmeKey content =
|
||||
content
|
||||
|> Link.findLinks 0 []
|
||||
|> List.concatMap (reportError context readmeKey)
|
||||
|
||||
|
||||
reportError : { projectName : String, version : String } -> Rule.ReadmeKey -> Node Link -> List (Error scope)
|
||||
reportError context readmeKey (Node range link) =
|
||||
case link.file of
|
||||
Link.ModuleTarget moduleName ->
|
||||
[ Rule.errorForReadmeWithFix readmeKey
|
||||
{ message = "Found relative link to a module in README"
|
||||
, details =
|
||||
[ "Relative links to other modules from the README don't work when looking at the docs from GitHub or the likes."
|
||||
, "I suggest to run elm-review --fix to change the link to an absolute link."
|
||||
]
|
||||
}
|
||||
range
|
||||
[ Fix.replaceRangeBy range <| "https://package.elm-lang.org/packages/" ++ context.projectName ++ "/" ++ context.version ++ "/" ++ String.join "-" moduleName ++ formatSlug link.slug ]
|
||||
]
|
||||
|
||||
Link.ReadmeTarget ->
|
||||
if link.startsWithDotSlash then
|
||||
[ Rule.errorForReadmeWithFix readmeKey
|
||||
{ message = "Found relative link from and to README"
|
||||
, details =
|
||||
[ "Links from and to README that start with \"./\" will not work on all places on GitHub or the likes."
|
||||
, "I suggest to remove the leading \"./\"."
|
||||
]
|
||||
}
|
||||
range
|
||||
(case link.slug of
|
||||
Just slug ->
|
||||
[ Fix.replaceRangeBy range ("#" ++ slug) ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
)
|
||||
]
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
Link.PackagesTarget { name, version, subTarget } ->
|
||||
if context.projectName == name && context.version /= version then
|
||||
[ Rule.errorForReadmeWithFix readmeKey
|
||||
{ message = "Link does not point to the current version of the package"
|
||||
, details = [ "I suggest to run elm-review --fix to get the correct link." ]
|
||||
}
|
||||
range
|
||||
[ Fix.replaceRangeBy range <| "https://package.elm-lang.org/packages/" ++ name ++ "/" ++ context.version ++ "/" ++ formatSubTarget subTarget ++ formatSlug link.slug ]
|
||||
]
|
||||
|
||||
else
|
||||
[]
|
||||
|
||||
Link.External _ ->
|
||||
[]
|
||||
|
||||
|
||||
formatSubTarget : Link.SubTarget -> String
|
||||
formatSubTarget subTarget =
|
||||
case subTarget of
|
||||
Link.ModuleSubTarget moduleName ->
|
||||
String.join "-" moduleName ++ "/"
|
||||
|
||||
Link.ReadmeSubTarget ->
|
||||
""
|
||||
|
||||
|
||||
formatSlug : Maybe String -> String
|
||||
formatSlug maybeSlug =
|
||||
case maybeSlug of
|
||||
Just slug ->
|
||||
"#" ++ slug
|
||||
|
||||
Nothing ->
|
||||
""
|
264
tests/Docs/UpToDateReadmeLinksTest.elm
Normal file
264
tests/Docs/UpToDateReadmeLinksTest.elm
Normal file
@ -0,0 +1,264 @@
|
||||
module Docs.UpToDateReadmeLinksTest exposing (all)
|
||||
|
||||
import Docs.UpToDateReadmeLinks exposing (rule)
|
||||
import Elm.Project
|
||||
import Json.Decode as Decode
|
||||
import Review.Project as Project exposing (Project)
|
||||
import Review.Test exposing (ReviewResult)
|
||||
import Test exposing (Test, describe, test)
|
||||
|
||||
|
||||
testRule : Project -> ReviewResult
|
||||
testRule project =
|
||||
"""module SomeModule exposing (a)
|
||||
a = 1"""
|
||||
|> Review.Test.runWithProjectData project rule
|
||||
|
||||
|
||||
createElmJson : String -> { path : String, raw : String, project : Elm.Project.Project }
|
||||
createElmJson rawElmJson =
|
||||
case Decode.decodeString Elm.Project.decoder rawElmJson of
|
||||
Ok elmJson ->
|
||||
{ path = "elm.json"
|
||||
, raw = rawElmJson
|
||||
, project = elmJson
|
||||
}
|
||||
|
||||
Err _ ->
|
||||
Debug.todo "Invalid elm.json supplied to test"
|
||||
|
||||
|
||||
packageElmJson : String -> String
|
||||
packageElmJson name =
|
||||
"""
|
||||
{
|
||||
"type": "package",
|
||||
"name": \""""
|
||||
++ name
|
||||
++ """",
|
||||
"summary": "Summary",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "1.2.3",
|
||||
"exposed-modules": [
|
||||
"Exposed"
|
||||
],
|
||||
"elm-version": "0.19.0 <= v < 0.20.0",
|
||||
"dependencies": {
|
||||
"elm/core": "1.0.0 <= v < 2.0.0"
|
||||
},
|
||||
"test-dependencies": {}
|
||||
}"""
|
||||
|
||||
|
||||
message : String
|
||||
message =
|
||||
"Link does not point to the current version of the package"
|
||||
|
||||
|
||||
details : List String
|
||||
details =
|
||||
[ "I suggest to run elm-review --fix to get the correct link." ]
|
||||
|
||||
|
||||
readmeWithLink : String -> String
|
||||
readmeWithLink link =
|
||||
"""
|
||||
# My project
|
||||
|
||||
- [my project's thing](""" ++ link ++ """)
|
||||
Don't report:
|
||||
- [this](https://package.elm-lang.org/packages/author/other-package/latest/Module-Name)
|
||||
- [this](https://package.elm-lang.org/packages/author/other-package/1.2.2/Module-Name)
|
||||
- [this](https://package.elm-lang.org/packages/other-author/package/latest/Module-Name)
|
||||
- [this](https://package.elm-lang.org/packages/other-author/package/1.2.4/Module-Name)
|
||||
"""
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Docs.UpToDateReadmeLinks"
|
||||
[ test "should not report an error if there is no elm.json file" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> addReadme "https://package.elm-lang.org/packages/author/package/1.2.4/Module-Name"
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should not report an error if there is no README file" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should not report an error if all the links point to the current project use the correct version" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> addReadme "https://package.elm-lang.org/packages/author/package/1.2.3/Module-Name"
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error if a link points to a different version" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> addReadme "https://package.elm-lang.org/packages/author/package/1.2.4/Module-Name"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/author/package/1.2.4/Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.3/Module-Name/")
|
||||
]
|
||||
, test "should report errors for multiple links on the same line" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> Project.addReadme { path = "README.md", content = """
|
||||
[link1](https://package.elm-lang.org/packages/author/package/1.2.4/A) [link2](https://package.elm-lang.org/packages/author/package/1.2.4/B)
|
||||
""" }
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/author/package/1.2.4/A"
|
||||
}
|
||||
|> Review.Test.whenFixed """
|
||||
[link1](https://package.elm-lang.org/packages/author/package/1.2.3/A/) [link2](https://package.elm-lang.org/packages/author/package/1.2.4/B)
|
||||
"""
|
||||
, Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/author/package/1.2.4/B"
|
||||
}
|
||||
|> Review.Test.whenFixed """
|
||||
[link1](https://package.elm-lang.org/packages/author/package/1.2.4/A) [link2](https://package.elm-lang.org/packages/author/package/1.2.3/B/)
|
||||
"""
|
||||
]
|
||||
, test "should report an error if a link points to latest" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> addReadme "https://package.elm-lang.org/packages/author/package/latest/Module-Name"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/author/package/latest/Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.3/Module-Name/")
|
||||
]
|
||||
, test "should report an error even if the author or package name contains a dash or digit" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> addReadme "https://package.elm-lang.org/packages/au-tho5r/pack-age1/latest/Module-Name"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/au-tho5r/pack-age1/latest/Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/au-tho5r/pack-age1/1.2.3/Module-Name/")
|
||||
]
|
||||
, test "should report an error if the link is relative" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> addReadme "Some-Module-Name"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = "Found relative link to a module in README"
|
||||
, details =
|
||||
[ "Relative links to other modules from the README don't work when looking at the docs from GitHub or the likes."
|
||||
, "I suggest to run elm-review --fix to change the link to an absolute link."
|
||||
]
|
||||
, under = "Some-Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/au-tho5r/pack-age1/1.2.3/Some-Module-Name")
|
||||
]
|
||||
, test "should report an error if the link is relative with a section" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> addReadme "Some-Module-Name#section"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = "Found relative link to a module in README"
|
||||
, details =
|
||||
[ "Relative links to other modules from the README don't work when looking at the docs from GitHub or the likes."
|
||||
, "I suggest to run elm-review --fix to change the link to an absolute link."
|
||||
]
|
||||
, under = "Some-Module-Name#section"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/au-tho5r/pack-age1/1.2.3/Some-Module-Name#section")
|
||||
]
|
||||
, test "should not report an error if the link is relative to the README" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> addReadme "#section"
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error if the link is relative to the README but starts with ./" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> addReadme "./#section"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = "Found relative link from and to README"
|
||||
, details =
|
||||
[ "Links from and to README that start with \"./\" will not work on all places on GitHub or the likes."
|
||||
, "I suggest to remove the leading \"./\"."
|
||||
]
|
||||
, under = "./#section"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "#section")
|
||||
]
|
||||
, test "should report an error but not provide a fix if the link is exactly ./" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> addReadme "./"
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = "Found relative link from and to README"
|
||||
, details =
|
||||
[ "Links from and to README that start with \"./\" will not work on all places on GitHub or the likes."
|
||||
, "I suggest to remove the leading \"./\"."
|
||||
]
|
||||
, under = "./"
|
||||
}
|
||||
]
|
||||
, test "should report at the correct location when there are unicode characters in the line" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> Project.addReadme { path = "README.md", content = """
|
||||
[🔧 `Rule.Name`](https://package.elm-lang.org/packages/au-tho5r/pack-age1/latest/)
|
||||
""" }
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = "Link does not point to the current version of the package"
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/au-tho5r/pack-age1/latest/"
|
||||
}
|
||||
|> Review.Test.whenFixed """
|
||||
[🔧 `Rule.Name`](https://package.elm-lang.org/packages/au-tho5r/pack-age1/1.2.3/)
|
||||
"""
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
addReadme : String -> Project -> Project
|
||||
addReadme contents =
|
||||
Project.addReadme { path = "README.md", content = readmeWithLink contents }
|
25
tests/Docs/Utils/ExposedFromProject.elm
Normal file
25
tests/Docs/Utils/ExposedFromProject.elm
Normal file
@ -0,0 +1,25 @@
|
||||
module Docs.Utils.ExposedFromProject exposing (exposedModules)
|
||||
|
||||
import Elm.Module
|
||||
import Elm.Project
|
||||
import Set exposing (Set)
|
||||
|
||||
|
||||
exposedModules : Elm.Project.Project -> Set String
|
||||
exposedModules project =
|
||||
case project of
|
||||
Elm.Project.Package package ->
|
||||
case package.exposed of
|
||||
Elm.Project.ExposedList list ->
|
||||
list
|
||||
|> List.map Elm.Module.toString
|
||||
|> Set.fromList
|
||||
|
||||
Elm.Project.ExposedDict list ->
|
||||
list
|
||||
|> List.concatMap Tuple.second
|
||||
|> List.map Elm.Module.toString
|
||||
|> Set.fromList
|
||||
|
||||
Elm.Project.Application _ ->
|
||||
Set.empty
|
335
tests/Docs/Utils/Link.elm
Normal file
335
tests/Docs/Utils/Link.elm
Normal file
@ -0,0 +1,335 @@
|
||||
module Docs.Utils.Link exposing
|
||||
( FileTarget(..)
|
||||
, Link
|
||||
, SubTarget(..)
|
||||
, findLinks
|
||||
)
|
||||
|
||||
import Elm.Syntax.ModuleName exposing (ModuleName)
|
||||
import Elm.Syntax.Node exposing (Node(..))
|
||||
import Elm.Syntax.Range exposing (Location, Range)
|
||||
import Parser exposing ((|.), (|=), Parser)
|
||||
import Regex exposing (Regex)
|
||||
|
||||
|
||||
addOffset : Int -> Range -> Range
|
||||
addOffset lineNumber { start, end } =
|
||||
{ start = addLineNumber lineNumber start
|
||||
, end = addLineNumber lineNumber end
|
||||
}
|
||||
|
||||
|
||||
addLineNumber : Int -> Location -> Location
|
||||
addLineNumber lineNumber { row, column } =
|
||||
{ row = lineNumber + row
|
||||
, column = column + 1
|
||||
}
|
||||
|
||||
|
||||
type alias Link =
|
||||
{ file : FileTarget
|
||||
, startsWithDotSlash : Bool
|
||||
, slug : Maybe String
|
||||
}
|
||||
|
||||
|
||||
type FileTarget
|
||||
= ModuleTarget ModuleName
|
||||
| ReadmeTarget
|
||||
| PackagesTarget { name : String, version : String, subTarget : SubTarget }
|
||||
| External String
|
||||
|
||||
|
||||
type SubTarget
|
||||
= ModuleSubTarget ModuleName
|
||||
| ReadmeSubTarget
|
||||
|
||||
|
||||
idParser : Char -> Parser String
|
||||
idParser endChar =
|
||||
Parser.succeed ()
|
||||
|. Parser.chompWhile (\c -> c /= endChar && c /= ' ')
|
||||
|> Parser.getChompedString
|
||||
|
||||
|
||||
onlyModuleNameParser : Parser ModuleName
|
||||
onlyModuleNameParser =
|
||||
Parser.succeed identity
|
||||
|= moduleNameParser
|
||||
|. Parser.end
|
||||
|
||||
|
||||
moduleNameParser : Parser ModuleName
|
||||
moduleNameParser =
|
||||
manySeparated
|
||||
{ by = "-"
|
||||
, item = moduleNameSegmentParser
|
||||
}
|
||||
|
||||
|
||||
moduleNameSegmentParser : Parser String
|
||||
moduleNameSegmentParser =
|
||||
Parser.succeed ()
|
||||
|. Parser.chompIf (\c -> Char.isUpper c)
|
||||
|. Parser.chompWhile (\c -> Char.isAlphaNum c)
|
||||
|> Parser.getChompedString
|
||||
|
||||
|
||||
findLinks : Int -> ModuleName -> String -> List (Node Link)
|
||||
findLinks row moduleName string =
|
||||
string
|
||||
|> String.lines
|
||||
|> List.indexedMap
|
||||
(\lineNumber lineContent ->
|
||||
lineContent
|
||||
|> Parser.run (findParser (linkParser (lineNumber + row) moduleName))
|
||||
|> Result.withDefault []
|
||||
|> List.filterMap identity
|
||||
|> List.indexedMap
|
||||
(\index (Node { start, end } link) ->
|
||||
Node
|
||||
{ start = { row = start.row, column = start.column - (index * 2) }
|
||||
, end = { row = end.row, column = end.column - (index * 2) }
|
||||
}
|
||||
link
|
||||
)
|
||||
)
|
||||
|> List.concat
|
||||
|
||||
|
||||
linkParser : Int -> ModuleName -> Parser (Maybe (Node Link))
|
||||
linkParser row moduleName =
|
||||
Parser.succeed identity
|
||||
|= Parser.getCol
|
||||
|. bracketsParser
|
||||
|> Parser.andThen
|
||||
(\col ->
|
||||
if col == 1 then
|
||||
Parser.oneOf
|
||||
[ inlineLinkParser
|
||||
|> Parser.map Just
|
||||
, referenceLinkParser
|
||||
|> Parser.map Just
|
||||
, Parser.succeed Nothing
|
||||
]
|
||||
|
||||
else
|
||||
Parser.oneOf
|
||||
[ Parser.map Just inlineLinkParser
|
||||
, Parser.succeed Nothing
|
||||
]
|
||||
)
|
||||
|> Parser.map
|
||||
(Maybe.map
|
||||
(\(Node range link) ->
|
||||
Node (addOffset row range) (normalizeModuleName moduleName link)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
normalizeModuleName : ModuleName -> Link -> Link
|
||||
normalizeModuleName currentModuleName link =
|
||||
case link.file of
|
||||
ModuleTarget [] ->
|
||||
let
|
||||
file : FileTarget
|
||||
file =
|
||||
if List.isEmpty currentModuleName then
|
||||
ReadmeTarget
|
||||
|
||||
else
|
||||
ModuleTarget currentModuleName
|
||||
in
|
||||
{ link | file = file }
|
||||
|
||||
ModuleTarget _ ->
|
||||
link
|
||||
|
||||
ReadmeTarget ->
|
||||
link
|
||||
|
||||
PackagesTarget _ ->
|
||||
link
|
||||
|
||||
External _ ->
|
||||
link
|
||||
|
||||
|
||||
{-| Parses things like:
|
||||
|
||||
This is a [link](#Link).
|
||||
|
||||
-}
|
||||
inlineLinkParser : Parser (Node Link)
|
||||
inlineLinkParser =
|
||||
Parser.succeed
|
||||
(\( startRow, startCol ) link ( endRow, endCol ) ->
|
||||
Node
|
||||
{ start = { row = startRow, column = startCol - 2 }
|
||||
, end = { row = endRow, column = endCol - 2 }
|
||||
}
|
||||
link
|
||||
)
|
||||
|. Parser.symbol "("
|
||||
|= Parser.getPosition
|
||||
|= pathParser ')'
|
||||
|= Parser.getPosition
|
||||
|. Parser.chompUntil ")"
|
||||
|. Parser.symbol ")"
|
||||
|
||||
|
||||
{-| Parses things like:
|
||||
|
||||
[link]: #Link
|
||||
|
||||
-}
|
||||
referenceLinkParser : Parser (Node Link)
|
||||
referenceLinkParser =
|
||||
Parser.succeed
|
||||
(\( startRow, startCol ) link ( endRow, endCol ) ->
|
||||
Node
|
||||
{ start = { row = startRow, column = startCol - 2 }
|
||||
, end = { row = endRow, column = endCol - 2 }
|
||||
}
|
||||
link
|
||||
)
|
||||
|. Parser.symbol ":"
|
||||
|. Parser.spaces
|
||||
|= Parser.getPosition
|
||||
|= pathParser '\n'
|
||||
|= Parser.getPosition
|
||||
|
||||
|
||||
pathParser : Char -> Parser Link
|
||||
pathParser endChar =
|
||||
Parser.oneOf
|
||||
[ Parser.succeed
|
||||
(\section ->
|
||||
{ file = ModuleTarget [], startsWithDotSlash = False, slug = Just section }
|
||||
)
|
||||
|. Parser.symbol "#"
|
||||
|= idParser endChar
|
||||
, Parser.succeed (\startsWithDotSlash file slug -> { file = file, startsWithDotSlash = startsWithDotSlash, slug = slug })
|
||||
|= ignoreDotSlash
|
||||
|= parseModuleName
|
||||
|= optionalSectionParser endChar
|
||||
]
|
||||
|
||||
|
||||
optionalSectionParser : Char -> Parser (Maybe String)
|
||||
optionalSectionParser endChar =
|
||||
Parser.oneOf
|
||||
[ Parser.succeed Just
|
||||
|. Parser.symbol "#"
|
||||
|= idParser endChar
|
||||
, Parser.succeed Nothing
|
||||
]
|
||||
|
||||
|
||||
parseModuleName : Parser FileTarget
|
||||
parseModuleName =
|
||||
Parser.succeed ()
|
||||
|. Parser.chompWhile (\c -> c /= '#' && c /= ')' && c /= ' ')
|
||||
|> Parser.getChompedString
|
||||
|> Parser.map
|
||||
(\linkTarget ->
|
||||
if linkTarget == "" then
|
||||
ReadmeTarget
|
||||
|
||||
else
|
||||
case Parser.run onlyModuleNameParser linkTarget of
|
||||
Ok moduleName ->
|
||||
ModuleTarget moduleName
|
||||
|
||||
Err _ ->
|
||||
case Regex.find linkRegex linkTarget |> List.head |> Maybe.andThen parseSubTarget of
|
||||
Just fileTarget ->
|
||||
fileTarget
|
||||
|
||||
Nothing ->
|
||||
External linkTarget
|
||||
)
|
||||
|
||||
|
||||
parseSubTarget : Regex.Match -> Maybe FileTarget
|
||||
parseSubTarget match =
|
||||
case match.submatches of
|
||||
(Just authorAndPackage) :: (Just linkVersion) :: _ :: rest :: [] ->
|
||||
let
|
||||
subTarget : SubTarget
|
||||
subTarget =
|
||||
case rest of
|
||||
Just nonemptyModuleName ->
|
||||
nonemptyModuleName
|
||||
|> String.replace "/" ""
|
||||
|> String.split "-"
|
||||
|> ModuleSubTarget
|
||||
|
||||
Nothing ->
|
||||
ReadmeSubTarget
|
||||
in
|
||||
Just (PackagesTarget { name = authorAndPackage, version = linkVersion, subTarget = subTarget })
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
linkRegex : Regex
|
||||
linkRegex =
|
||||
Regex.fromString "https://package\\.elm-lang\\.org/packages/([\\w-]+/[\\w-]+)/(latest|\\w+\\.\\w+\\.\\w+)(/(.*))?"
|
||||
|> Maybe.withDefault Regex.never
|
||||
|
||||
|
||||
ignoreDotSlash : Parser Bool
|
||||
ignoreDotSlash =
|
||||
Parser.oneOf
|
||||
[ Parser.symbol "."
|
||||
|. Parser.symbol "/"
|
||||
|> Parser.map (\_ -> True)
|
||||
, Parser.succeed False
|
||||
]
|
||||
|
||||
|
||||
bracketsParser : Parser ()
|
||||
bracketsParser =
|
||||
Parser.succeed identity
|
||||
|. Parser.symbol "["
|
||||
|. Parser.spaces
|
||||
|= Parser.chompUntil "]"
|
||||
|. Parser.spaces
|
||||
|. Parser.symbol "]"
|
||||
|
||||
|
||||
findParser : Parser a -> Parser (List a)
|
||||
findParser parser =
|
||||
Parser.loop []
|
||||
(\parsed ->
|
||||
Parser.oneOf
|
||||
[ Parser.succeed (\p -> p :: parsed)
|
||||
|= parser
|
||||
|> Parser.map Parser.Loop
|
||||
, Parser.succeed parsed
|
||||
|. Parser.chompIf (\_ -> True)
|
||||
|> Parser.map Parser.Loop
|
||||
, Parser.end
|
||||
|> Parser.map (\() -> Parser.Done (List.reverse parsed))
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
{-| 0 or more things directly separated by a string like "go-gi-ga".
|
||||
-}
|
||||
manySeparated :
|
||||
{ by : String
|
||||
, item : Parser between
|
||||
}
|
||||
-> Parser (List between)
|
||||
manySeparated { by, item } =
|
||||
Parser.sequence
|
||||
{ start = ""
|
||||
, separator = by
|
||||
, end = ""
|
||||
, spaces = Parser.symbol ""
|
||||
, item = item
|
||||
, trailing = Parser.Forbidden
|
||||
}
|
41
tests/Docs/Utils/Slug.elm
Normal file
41
tests/Docs/Utils/Slug.elm
Normal file
@ -0,0 +1,41 @@
|
||||
module Docs.Utils.Slug exposing (toSlug)
|
||||
|
||||
import Regex exposing (Regex)
|
||||
|
||||
|
||||
toSlug : String -> String
|
||||
toSlug string =
|
||||
string
|
||||
|> String.toLower
|
||||
|> Regex.replace specialsToStrip (\_ -> "")
|
||||
|> Regex.replace specialsToReplaceByDash (\_ -> "-")
|
||||
|> Regex.replace specialsToReplaceByUnderscore (\_ -> "_")
|
||||
|> Regex.replace multipleDashes (\_ -> "-")
|
||||
|
||||
|
||||
specialsToReplaceByDash : Regex
|
||||
specialsToReplaceByDash =
|
||||
"([.() \\[\\]`?]|\\\\)"
|
||||
|> Regex.fromString
|
||||
|> Maybe.withDefault Regex.never
|
||||
|
||||
|
||||
specialsToReplaceByUnderscore : Regex
|
||||
specialsToReplaceByUnderscore =
|
||||
"\\*"
|
||||
|> Regex.fromString
|
||||
|> Maybe.withDefault Regex.never
|
||||
|
||||
|
||||
specialsToStrip : Regex
|
||||
specialsToStrip =
|
||||
"[~$]|\\[`|`\\]"
|
||||
|> Regex.fromString
|
||||
|> Maybe.withDefault Regex.never
|
||||
|
||||
|
||||
multipleDashes : Regex
|
||||
multipleDashes =
|
||||
"-+"
|
||||
|> Regex.fromString
|
||||
|> Maybe.withDefault Regex.never
|
36
tests/Docs/Utils/SlugTest.elm
Normal file
36
tests/Docs/Utils/SlugTest.elm
Normal file
@ -0,0 +1,36 @@
|
||||
module Docs.Utils.SlugTest exposing (all)
|
||||
|
||||
import Docs.Utils.Slug as Slug
|
||||
import Expect
|
||||
import Test exposing (Test, describe, test)
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "Id slugs"
|
||||
[ test "should slugify single-word section" <|
|
||||
\() ->
|
||||
"Section"
|
||||
|> Slug.toSlug
|
||||
|> Expect.equal "section"
|
||||
, test "should slugify section with spaces" <|
|
||||
\() ->
|
||||
"Some Section"
|
||||
|> Slug.toSlug
|
||||
|> Expect.equal "some-section"
|
||||
, test "should slugify section with back-ticks" <|
|
||||
\() ->
|
||||
"`section`"
|
||||
|> Slug.toSlug
|
||||
|> Expect.equal "-section-"
|
||||
, test "should slugify section with question mark" <|
|
||||
\() ->
|
||||
"Section?"
|
||||
|> Slug.toSlug
|
||||
|> Expect.equal "section-"
|
||||
, test "should slugify complex example" <|
|
||||
\() ->
|
||||
"Section *with* ~some~ _spaces_ and\\_ $thi.ngs . [`links`](foo)"
|
||||
|> Slug.toSlug
|
||||
|> Expect.equal "section-_with_-some-_spaces_-and-_-thi-ngs-links-foo-"
|
||||
]
|
@ -1,147 +0,0 @@
|
||||
module Documentation.ReadmeLinksPointToCurrentVersion exposing (rule)
|
||||
|
||||
{-|
|
||||
|
||||
@docs rule
|
||||
|
||||
-}
|
||||
|
||||
import Elm.Package
|
||||
import Elm.Project
|
||||
import Elm.Syntax.Range exposing (Range)
|
||||
import Elm.Version
|
||||
import Regex exposing (Regex)
|
||||
import Review.Fix as Fix
|
||||
import Review.Rule as Rule exposing (Error, Rule)
|
||||
|
||||
|
||||
{-| Reports links in the `README.md` that point to this project's package documentation on <https://package.elm-lang.org/>,
|
||||
where the version is set to `latest` or a different version than the current version of the package.
|
||||
|
||||
The problem with linking to `latest` is that if you release a new version later,
|
||||
the users who read the README for the older version will be directed to a version
|
||||
where the module/function/section you pointed to may not exist anymore.
|
||||
|
||||
This rule ensures that you always use the correct version in all of your releases,
|
||||
and that you do not forget to update the links.
|
||||
|
||||
This rule provides automatic fixes, so you won't to do the tedious job of updating
|
||||
the links yourself.
|
||||
|
||||
**NOTE**: Just make sure to run tests between bumping the version of the package
|
||||
and publishing the package. Otherwise the link for a given version could link to a previous one.
|
||||
|
||||
**NOTE**: A similar rule would be useful for links inside the modules. I'll be working on that too!
|
||||
|
||||
|
||||
## Try it out
|
||||
|
||||
You can try this rule out by running the following command:
|
||||
|
||||
```bash
|
||||
elm-review --template jfmengels/elm-review-documentation/example --rules NoUselessSubscriptions
|
||||
```
|
||||
|
||||
-}
|
||||
rule : Rule
|
||||
rule =
|
||||
Rule.newProjectRuleSchema "ReadmeLinksPointToCurrentVersion" initialProjectContext
|
||||
|> Rule.withElmJsonProjectVisitor elmJsonVisitor
|
||||
|> Rule.withReadmeProjectVisitor readmeVisitor
|
||||
|> Rule.fromProjectRuleSchema
|
||||
|
||||
|
||||
type alias ProjectContext =
|
||||
Maybe
|
||||
{ projectName : String
|
||||
, version : String
|
||||
}
|
||||
|
||||
|
||||
initialProjectContext : ProjectContext
|
||||
initialProjectContext =
|
||||
Nothing
|
||||
|
||||
|
||||
|
||||
-- elm.json VISITOR
|
||||
|
||||
|
||||
elmJsonVisitor : Maybe { a | project : Elm.Project.Project } -> ProjectContext -> ( List nothing, ProjectContext )
|
||||
elmJsonVisitor maybeProject _ =
|
||||
case maybeProject |> Maybe.map .project of
|
||||
Just (Elm.Project.Package pkg) ->
|
||||
( []
|
||||
, Just
|
||||
{ projectName = Elm.Package.toString pkg.name
|
||||
, version = Elm.Version.toString pkg.version
|
||||
}
|
||||
)
|
||||
|
||||
_ ->
|
||||
( [], Nothing )
|
||||
|
||||
|
||||
|
||||
-- README VISITOR
|
||||
|
||||
|
||||
readmeVisitor : Maybe { readmeKey : Rule.ReadmeKey, content : String } -> ProjectContext -> ( List (Error scope), ProjectContext )
|
||||
readmeVisitor maybeReadme maybeContext =
|
||||
case ( maybeReadme, maybeContext ) of
|
||||
( Just { readmeKey, content }, Just context ) ->
|
||||
( findRangeForSubstring context readmeKey content, maybeContext )
|
||||
|
||||
_ ->
|
||||
( [], maybeContext )
|
||||
|
||||
|
||||
linkRegex : Regex
|
||||
linkRegex =
|
||||
Regex.fromString "]\\(https://package\\.elm-lang\\.org/packages/([\\w-]+/[\\w-]+)/(\\w+(\\.\\w+\\.\\w+)?)(.*)\\)"
|
||||
|> Maybe.withDefault Regex.never
|
||||
|
||||
|
||||
findRangeForSubstring : { projectName : String, version : String } -> Rule.ReadmeKey -> String -> List (Error scope)
|
||||
findRangeForSubstring context readmeKey content =
|
||||
content
|
||||
|> String.lines
|
||||
|> List.indexedMap Tuple.pair
|
||||
|> List.concatMap
|
||||
(\( row, line ) ->
|
||||
Regex.find linkRegex line
|
||||
|> List.filterMap (notAMatch context readmeKey row)
|
||||
)
|
||||
|
||||
|
||||
notAMatch : { projectName : String, version : String } -> Rule.ReadmeKey -> Int -> Regex.Match -> Maybe (Error scope)
|
||||
notAMatch { projectName, version } readmeKey row match =
|
||||
case match.submatches of
|
||||
(Just authorAndPackage) :: (Just linkVersion) :: _ :: rest :: [] ->
|
||||
if authorAndPackage == projectName && linkVersion /= version then
|
||||
let
|
||||
range : Range
|
||||
range =
|
||||
{ start =
|
||||
{ row = row + 1
|
||||
, column = match.index + 3
|
||||
}
|
||||
, end =
|
||||
{ row = row + 1
|
||||
, column = match.index + String.length match.match
|
||||
}
|
||||
}
|
||||
in
|
||||
Rule.errorForReadmeWithFix readmeKey
|
||||
{ message = "Link does not point to the current version of the package"
|
||||
, details = [ "I suggest to run elm-review --fix to get the correct links." ]
|
||||
}
|
||||
range
|
||||
[ Fix.replaceRangeBy range <| "https://package.elm-lang.org/packages/" ++ projectName ++ "/" ++ version ++ Maybe.withDefault "" rest ]
|
||||
|> Just
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
_ ->
|
||||
Nothing
|
@ -1,141 +0,0 @@
|
||||
module Documentation.ReadmeLinksPointToCurrentVersionTest exposing (all)
|
||||
|
||||
import Documentation.ReadmeLinksPointToCurrentVersion exposing (rule)
|
||||
import Elm.Project
|
||||
import Json.Decode as Decode
|
||||
import Review.Project as Project exposing (Project)
|
||||
import Review.Test exposing (ReviewResult)
|
||||
import Test exposing (Test, describe, test)
|
||||
|
||||
|
||||
testRule : Project -> ReviewResult
|
||||
testRule project =
|
||||
"""module SomeModule exposing (a)
|
||||
a = 1"""
|
||||
|> Review.Test.runWithProjectData project rule
|
||||
|
||||
|
||||
createElmJson : String -> { path : String, raw : String, project : Elm.Project.Project }
|
||||
createElmJson rawElmJson =
|
||||
case Decode.decodeString Elm.Project.decoder rawElmJson of
|
||||
Ok elmJson ->
|
||||
{ path = "elm.json"
|
||||
, raw = rawElmJson
|
||||
, project = elmJson
|
||||
}
|
||||
|
||||
Err _ ->
|
||||
Debug.todo "Invalid elm.json supplied to test"
|
||||
|
||||
|
||||
packageElmJson : String -> String
|
||||
packageElmJson name =
|
||||
"""
|
||||
{
|
||||
"type": "package",
|
||||
"name": \""""
|
||||
++ name
|
||||
++ """",
|
||||
"summary": "Summary",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "1.2.3",
|
||||
"exposed-modules": [
|
||||
"Exposed"
|
||||
],
|
||||
"elm-version": "0.19.0 <= v < 0.20.0",
|
||||
"dependencies": {
|
||||
"elm/core": "1.0.0 <= v < 2.0.0"
|
||||
},
|
||||
"test-dependencies": {}
|
||||
}"""
|
||||
|
||||
|
||||
message : String
|
||||
message =
|
||||
"Link does not point to the current version of the package"
|
||||
|
||||
|
||||
details : List String
|
||||
details =
|
||||
[ "I suggest to run elm-review --fix to get the correct links." ]
|
||||
|
||||
|
||||
readmeWithLink : String -> String
|
||||
readmeWithLink link =
|
||||
"""
|
||||
# My project
|
||||
|
||||
- [my project's thing](""" ++ link ++ """)
|
||||
Don't report:
|
||||
- [this](https://package.elm-lang.org/packages/author/other-package/latest/Module-Name)
|
||||
- [this](https://package.elm-lang.org/packages/author/other-package/1.2.2/Module-Name)
|
||||
- [this](https://package.elm-lang.org/packages/other-author/package/latest/Module-Name)
|
||||
- [this](https://package.elm-lang.org/packages/other-author/package/1.2.4/Module-Name)
|
||||
"""
|
||||
|
||||
|
||||
all : Test
|
||||
all =
|
||||
describe "ReadmeLinksPointToCurrentVersion"
|
||||
[ test "should not report an error if there is no elm.json file" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addReadme { path = "README.md", content = readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.4/Module-Name" }
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should not report an error if there is no README file" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should not report an error if all the links point to the current project use the correct version" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> Project.addReadme { path = "README.md", content = readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.3/Module-Name" }
|
||||
|> testRule
|
||||
|> Review.Test.expectNoErrors
|
||||
, test "should report an error if a link points to a different version" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> Project.addReadme { path = "README.md", content = readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.4/Module-Name" }
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/author/package/1.2.4/Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.3/Module-Name")
|
||||
]
|
||||
, test "should report an error if a link points to latest" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "author/package")
|
||||
|> Project.addReadme { path = "README.md", content = readmeWithLink "https://package.elm-lang.org/packages/author/package/latest/Module-Name" }
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/author/package/latest/Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/author/package/1.2.3/Module-Name")
|
||||
]
|
||||
, test "should report an error even if the author or package name contains a dash or digit" <|
|
||||
\() ->
|
||||
Project.new
|
||||
|> Project.addElmJson (createElmJson <| packageElmJson "au-tho5r/pack-age1")
|
||||
|> Project.addReadme { path = "README.md", content = readmeWithLink "https://package.elm-lang.org/packages/au-tho5r/pack-age1/latest/Module-Name" }
|
||||
|> testRule
|
||||
|> Review.Test.expectErrorsForReadme
|
||||
[ Review.Test.error
|
||||
{ message = message
|
||||
, details = details
|
||||
, under = "https://package.elm-lang.org/packages/au-tho5r/pack-age1/latest/Module-Name"
|
||||
}
|
||||
|> Review.Test.whenFixed (readmeWithLink "https://package.elm-lang.org/packages/au-tho5r/pack-age1/1.2.3/Module-Name")
|
||||
]
|
||||
]
|
Loading…
Reference in New Issue
Block a user