ExtraCheck

A check inspects modules, gren.json, dependencies and extra files like README.md from your project and uses the combined knowledge to report problems.

ignoreErrorsForPathsWhere : (String -> Bool) -> Check -> Check

Specify where you don't want errors to be reported in, as posix paths:

  • external libraries you copied over and don't want to modify more than necessary
  • generated source code which isn't supposed to be read and or over which you have little control
  • you wrote a check that is very specific and should only be applied for a portion of your codebase

(tests/ is never implicitly inspected since it's a standalone gren project)

Everything below is intended for writing a new check.

create :
{ inspectModule : { path : String, source : String, comments : Dict Int (Array Comment)
, syntax : Module
} -> knowledge
, inspectGrenJson : { source : String, outline : Outline
} -> knowledge
, inspectExtraFile : { path : String, source : String
} -> knowledge
, inspectDirectDependencies : Array { outline : PkgOutline, modules : Array { syntax : Module, source : String }
} -> knowledge
, knowledgeMerge : knowledge -> knowledge -> knowledge
, report : knowledge -> Array Error
}
-> Check

To write a new Check you need to

  • inspectModule: collect knowledge from module syntax. More info below
  • inspectGrenJson: collect knowledge from the gren.json project config. You can use the raw source to search for a section you want to highlight in an Error
  • inspectExtraFile: collect knowledge from an available files like README.md, CHANGELOG.md or package.json that aren't source modules or the gren.json. The provided path is relative to the project's gren.json and can be used as an Error target file path
  • inspectDirectDependencies: collect knowledge from all packages and their modules the project directly depends on
  • knowledgeMerge: combine collected knowledge from different parts
  • report: evaluate your combined knowledge and provide errors

For inspectModule, the path is relative to the project's gren.json and can be used as an Error target file path.

The raw gren file source can be used to preserve existing formatting in a region in when you want to move and copy things around, see sourceExtractInRegion.

The syntax field contains a tree-like structure which represents your source code: Compiler.Ast.Source.Module. I recommend having the documentation for that package open while writing a check.

The module's import statements (import Html as H exposing (div)) in order of their definition.

An example check that forbids importing both Element and Html.Styled in the same module:

import SourcePosition
import ExtraCheck
import Dict
import Array.Builder

check : ExtraCheck.Check
check =
    ExtraCheck.create
        { inspectModule =
            \moduleData ->
                let
                    importedModuleNames : Dict String SourcePosition.Region
                    importedModuleNames =
                        moduleData.syntax.imports
                            |> Array.foldl
                                (\{ value = import_ } soFar ->
                                    soFar
                                        |> Dict.set import_.module_.value
                                            (import_.module_ |> ExtraCheck.locatedRegion)
                                )
                                Dict.empty
                in
                when
                    Maybe.map2 (\elementImportRegion _ -> elementImportRegion)
                        (importedModuleNames |> Dict.get "Element")
                        (importedModuleNames |> Dict.get "Html.Styled")
                is
                    Just elementImportRegion ->
                        Dict.singleton moduleData.path elementImportRegion

                    _ ->
                        Dict.empty
        , inspectDirectDependencies = \_ -> Dict.empty
        , inspectGrenJson = \_ -> Dict.empty
        , inspectExtraFile = \_ -> Dict.empty
        , knowledgeMerge = \a b -> Dict.union a b
        , report =
            \invalidImportsByPath ->
                invalidImportsByPath
                    |> Dict.foldl
                        (\{ key = path, value = elementImportRegion } soFar ->
                            soFar
                                |> Array.Builder.pushLast
                                    { path = moduleData.path
                                    , message = "both `Element` and `Html.Styled` used"
                                    , details = "At fruits.com, we use `Element` in the dashboard application, and `Html.Styled` in the rest of the code. We want to use `Element` in our new projects, but in projects using `Html.Styled`, we don't want to use both libraries to keep things simple."
                                    , region = elementImportRegion
                                    , fix = []
                                    }
                        )
                        (Array.Builder.empty 0)
                    |> Array.Builder.toArray
        }

For more complex examples of checks, check out the checks in this package like DebugIsNotUsed.check

Alongside your implementation, write tests, document what is enforced, what the goal is and which situations the check makes sense and add examples of patterns that will (not) be reported.

reporting

type alias Error =
{ path : String
, region : Region
, message : String
, details : String
, fix : Array { path : String, edits : Array SourceEdit }
}

A problem to report in a given file and region, possibly suggesting a fix with SourceEdits

Take the gren compiler errors as inspiration in terms of helpfulness:

  • error message: half-sentence on what is bad here. A user that has encountered this error multiple times should know exactly what to do. Example: "Module.helper is never used" → this helper can be removed
  • error details: additional information such as the rationale and suggestions for a solution or alternative
  • error report region: The region marked as problematic. Make this section as small as possible. For instance, in a check that would forbid Debug.log, you would want the error to appear at Debug.log, not on the whole function call
grenJsonPath : String

Relative path to the project gren.json to be used in an Error or expected test error. Constant for "gren.json"

type alias SourceEdit = { region : Region, replacement : String }

A single edit to be applied to a file's source in order to fix a check error

insertAt : Position -> String -> SourceEdit

Insert something at the given position

removeRegion : Region -> SourceEdit

Remove the section in between a given region

replaceRegion : Region -> String -> SourceEdit

Replace the section in between a given region by something

convenience helpers

Various helpers for Compiler.Ast.Source and other project data that is often useful when writing a check.

For example, by only listing all immediate sub-parts of a piece of syntax you can choose to traverse it however you need to. E.g. to find all as pattern regions

pushAllAsPatternRegionsInto :
    Array.Builder.Builder SourcePosition.Region
    -> Compiler.Ast.Source.Pattern
    -> Array.Builder.Builder SourcePosition.Region
pushAllAsPatternRegionsInto builder pattern =
    pattern.value
        |> ExtraCheck.patternImmediateSubsFold
            (\sub withSubsSoFar ->
                findAllAsPatternRegionsInto withSubsSoFar sub
            )
            (when pattern.value is
                Compiler.Ast.Source.PAlias _ ->
                    builder |> Array.Builder.pushLast (pattern |> ExtraPath.locatedRegion)

                _ ->
                    builder
            )

This fine control allows you to e.g. skip visiting certain parts that you already accounted for.

expressionImmediateSubsFold :
(Expression -> state -> state)
-> state
-> Expression_
-> state

Walk immediate child-expressions, collecting information on the way. To additionally track locally introduced variables, use expressionImmediateSubsFoldWithIntroducedVariables

expressionImmediateSubsFoldWithIntroducedVariables :
Dict String LocalVariableOrigin
-> (Expression -> Dict String LocalVariableOrigin -> state -> state)
-> state
-> Expression
-> state

Walk immediate child-expressions, tracking what new bindings have been added to the name scope for each sub-expression, collecting information on the way.

Don't forget to include function declaration parameter variables in the given dict using patternArrayVariablesFold.

Knowing local variable names in all parts of an expression is useful for knowing if a variable refers to an import-exposed (or implicitly imported) name or is unrelated. When you want to fix code by inserting references like identity, first check for local variables and if necessary insert Basics/WhateverImportAliasIsUsed.identity instead.

Note that in these cases, you also need to check for module-declared variable names first.

type LocalVariableOrigin
= OriginLetDefineVariable ({ variableDefine : Located DefineRecord, outerLetRegion : Region, outerLetAllDefs : Array (Located Def), outerLetBody : Expression })
| OriginPatternPunnedField Region
| OriginPatternVariable Region

Place where a local variable is introduced

patternImmediateSubsFold : (Pattern -> state -> state) -> state -> Pattern_ -> state

Walk immediate child-patterns, collecting information on the way

typeImmediateSubsFold : (Type -> state -> state) -> state -> Type_ -> state

Walk immediate child-patterns, collecting information on the way

patternVariablesFold :
(String -> LocalVariableOrigin -> state -> state)
-> state
-> Pattern
-> state

Fold over all the bindings contained (deep) within the given pattern. Typically used with patternVariablesFold Dict.set currentLocalVariables pattern

patternArrayVariablesFold :
(String -> LocalVariableOrigin -> state -> state)
-> state
-> Array Pattern
-> state

Fold over all the bindings contained (deep) within the given patterns. Typically used with patternArrayVariablesFold Dict.set Dict.empty declarationParameters

locatedRegion : Located value -> Region

Package the start and end positions of a located syntax node into a SourcePosition.Region.

replaceExpressionByEmptyArrayFix : Compiler.Ast.Source.Expression -> ExtraCheck.SourceEdit
replaceExpressionByEmptyArrayFix =
    ExtraCheck.replaceRegion
        (expression |> ExtraCheck.locatedRegion)
        "[]"
sourceExtractInRegion : Region -> String -> String

In when you need the read the source in a region as formatted by the user. This can be nice to keep the user's formatting if you just move code around

sourceRegionsOf : String -> String -> Array Region

Find all occurrences of a given section in the source in the when you can't access or easily calculate the region. Use sparingly

sourceUnitIndexToPosition : String -> Int -> Position

Convert an offset into the source (measured in units) into a region. Use sparingly

outlineExposedModuleNameSet : Exposed -> Set String

The set of modules in the exposed-modules field in a package gren.json. Only use this helper if you need a set, otherwise prefer outlineExposedModuleNames which produces an array and is faster.

outlineExposedModuleNames : Exposed -> Array ModuleName

The module names listed in the exposed-modules field in a package gren.json. If you need a set, use outlineExposedModuleNameSet

implicitImports : Dict String { alias : Maybe String, exposes : Set String }

From the gren-lang/core readme:

The modules in this package are so common, that some of them are imported by default in all Gren files. So it is as if every Gren file starts with these imports:

import Basics exposing (..)
import Array exposing (Array)
import Maybe exposing (Maybe(..))
import Result exposing (Result(..))
import String exposing (String)
import Char exposing (Char)

import Debug

import Platform exposing (Program)
import Platform.Cmd as Cmd exposing (Cmd)
import Platform.Sub as Sub exposing (Sub)

Suggestions to add, remove or change helpers welcome! sourceRegionsOf

testing

Using gren-lang/test.

Implementing a check works really well in a Test-Driven loop:

  • write a failing test before writing any code. Run it to make sure that it is failing. Also write tests to ensure cases similar to what you want to report are not reported. For instance, if you wish to report uses of variables named foo, write a test that ensures that the use of variables named differently does not get reported
  • write simple (almost stupid) check code to make the test pass, using Debug.todo etc to cut corner cases
  • run the tests again and make sure that both new and previous tests are passing

Then repeat for every when that needs to be handled. Ideally, by only reading through the test titles, someone else should be able to recreate the check you are testing.

Tests are cheap and it is better to have too many tests rather than too few, since the behavior of a check rarely changes drastically.

expectToReport :
Array { path : String
, message : String
, details : String
, region : ExpectedErrorRegion
, fixedFiles : Array { path : String, source : String }
}
-> { check : Check, projectConfiguration : { grenJson : String, directDependencies : Array { outline : PkgOutline, modules : Array { syntax : Module, source : String }
} }
, files : Array { path : String, source : String }
}
-> Expectation

Run a Check on a project, matching all reported module errors, gren.json errors and extra file errors with your provided expected errors.

import DebugForbid
import ExtraCheck
import Test

tests : Test.Test
tests =
    Test.describe "DebugForbid"
        [ Test.test "report Debug.log use"
            (\{} ->
                { check = DebugForbid.check
                , projectConfiguration = ExtraCheck.applicationConfigurationMinimal
                , files =
                    [ { path = "src/A.gren"
                        , source =
                            """
                            module A exposing (a)
                            import Html
                            a =
                                Debug.log "some" "message"
                            """
                        }
                    ]
                }
                    |> ExtraCheck.expectToReport
                        [ { path = "src/A.gren"
                          , message = "Remove the use of `Debug` before shipping to production"
                          , details = "Compiling gren code in optimized mode does not allow these helpers."
                          , region = ExtraCheck.ExpectUnder "Debug.log"
                          , fixedFiles =
                                [ { path = "src/A.gren"
                                  , source =
                                        """
                                        module A exposing (a)
                                        a =
                                            "message"
                                        """
                                  }
                                ]
                          }
                        ]
            )
        ]

To expect no errors, use ... |> expectToReport [].

You can also specify a custom project config for when you want to test a package, use a custom gren.json or add dependencies by supplying the raw sources for the gren.json and the direct dependency modules (the most common gren-lang/core modules are automatically part of every tested project).

If you find that these tests are overly verbose, I encourage you to create local helpers. I personally prefer that over exposing these from the ExtraCheck module, as trying to make assumptions about your most common test scenarios seems questionable to me.

applicationConfigurationMinimal :
{ grenJson : String
, directDependencies : Array { outline : PkgOutline, modules : Array { syntax : Module, source : String }
}
}

The default project after gren init --platform=common (no browser/node dependencies). Equivalent to (copy and adapt if necessary)

{ grenJson =
    """
    {
        "type": "application",
        "platform": "common",
        "source-directories": [
            "src"
        ],
        "gren-version": "0.6.3",
        "dependencies": {
            "direct": {
                "gren-lang/core": "7.4.0"
            },
            "indirect": {}
        }
    }
    """
, directDependencies = []
}

(the most common gren-lang/core modules are automatically part of every tested project)

type ExpectedErrorRegion
= ExpectUnder String
| ExpectUnderExactly ({ section : String, startingAt : Position })

An expectation for the reported error region. If the section to mark is unique in the source, use Under "your section source". If the section occurs multiple times in the source, use UnderExactly { section = "your section source", startingAt = { row = ..., col = ... } }

running

Make gren-extra-checks run in a new environment

type alias Check = { ignoreErrorsForPathsWhere : String -> Bool, run : Run }

A construct that can inspect your project files and report errors, see ExtraCheck.create

run :
Run
-> { ignoreErrorsForPathsWhere : String -> Bool, grenJson : { source : String, outline : Outline
}
, directDependencies : Array { outline : PkgOutline, modules : Array { syntax : Module, source : String }
}
, addedOrChangedExtraFiles : Array { path : String, source : String }
, addedOrChangedModules : Array { path : String, source : String, comments : Dict Int (Array Comment)
, syntax : Module }
, removedExtraFilePaths : Array String
, removedModulePaths : Array String
}
-> { errorsByPath : Dict String (Array { region : Region, message : String, details : String, fixEditsByPath : Dict String ( Array SourceEdit)
})
, nextRun : Run
}

ExtraCheck a given project and return the errors reported by the given Check as a dict along with a next Run that uses cached knowledge wherever it can.

import ExtraCheck
import SomeConvention

doCheck =
    let
        project =
            { ignoreErrorsForPathsWhere = SomeConvention.check.ignoreErrorsForPathsWhere
            , addedOrChangedModules =
                [ { path = "src/A.gren", source = "module A exposing (a)\na = 1", syntax = ... }
                , { path = "src/B.gren", source = "module B exposing (b)\nb = 1", syntax = ... }
                ]
            , ...
            }

        runResult =
            ExtraCheck.run SomeConvention.check.run project
    in
    doSomethingWith runResult.errorsByPath

The resulting next Run internally keeps a cache to make it faster to re-run the check when only some files have changed. You can store this resulting nextRun in your application state type, e.g.

ExtraCheck.run
    yourApplicationState.nextRunProvidedByTheLastCheckRun
    SomeConvention.check.ignoreErrorsForPathsWhere
    { addedOrChangedModules =
        [ { path = "src/C.gren", source = "module C exposing (c)\nc = 1", syntax = ... }
        ]
    , removedModulePaths = [ "src/B.gren" ]
    , ...
    }
sourceApplyEdits : Array SourceEdit -> String -> Result SourceEditError String

Try to apply a set of SourceEdites to the relevant file source, potentially failing with a SourceEditError

type SourceEditError
= AfterFixIsUnchanged
| FixHasCollisionsInRegions

An undesired situation when trying to apply SourceEdits

type Run
= Run ({ ignoreErrorsForPathsWhere : String -> Bool, grenJson : { source : String, outline : Outline }, directDependencies : Array { outline : PkgOutline, modules : Array { syntax : Module, source : String } }, addedOrChangedExtraFiles : Array { path : String, source : String }, addedOrChangedModules : Array { path : String, source : String, comments : Dict Int (Array Comment), syntax : Module }, removedExtraFilePaths : Array String, removedModulePaths : Array String } -> { errorsByPath : Dict String (Array { region : Region, message : String, details : String, fixEditsByPath : Dict String (Array SourceEdit) }), nextRun : Run })

A function that inspects, folds knowledge and reports errors in one go, and provides a recursively-defined future run function that already has the calculated knowledges cached.

Does it need to be so complicated?

Different checks can have different knowledge types, so how can we put all of them in a single list? No problem, hide this parameter by only storing the combined run function from project files to errors.

But how do we implement caching this way (only re-run inspections for files that changed, otherwise take the already computed knowledge parts)? I know about roughly three options:

  • require users to provide encode/decode pairs to some generic structure

    • the ability to use the encoded cache at any time enables write to disk (not a goal of gren-extra-checks but having the option is nice)
    • the ability to show the encoded cache in a gren debugger (undeniably cool, though that only works in the browser. Debug.log is likely a nice enough alternative)
    • the ability to export the produced json for external use (not a goal of gren-extra-checks but undeniably cool, similar to elm-review insights/extract although less explicit)
    • quite a burden users should preferably not bear
  • use js shenanigans to effectively "cast specific context type to any" and "cast any to specific context type" similar to linsyking/elm-anytype

    • faster than any possible alternative
    • simpler gren internals than any possible alternative
    • the highest possible degree of danger, as incorrect casting is always possible (+ no way to control that ExtraCheck.run users wire them correctly)
    • custom embeds of running checks (e.g. if you wanted to create a browser playground) always need additional js hacking which to me is deal breaking
  • provide a future function as a result which knows about the previously calculated knowledges. I was surprised to find that it's almost trivial to implement and even cuts down internal complexity compared to something like the codec one. If you're intrigued and have a week, some smart folks have written an entertaining dialog-blog-ish series about these "Jeremy's interfaces"

    • still very fast (much faster than the codec one)
    • concise internals
    • possibly less flexible for implementing stuff like shared knowledge (I much prefer defunctionalization wherever possible)
    • slightly less obvious than the codec one

I didn't expect to ever need to resort to such a complex feature but here we are. Though I'm always excited to try and experiment with other options (except the codec one which I already tried), issues welcome!