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.
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.
To write a new Check you need to
inspectModule: collect knowledge from module syntax. More info belowinspectGrenJson: collect knowledge from the gren.json project config. You can use the raw source to search for a section you want to highlight in anErrorinspectExtraFile: 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'sgren.jsonand can be used as anErrortarget file pathinspectDirectDependencies: collect knowledge from all packages and their modules the project directly depends onknowledgeMerge: combine collected knowledge from different partsreport: 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
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 forbidDebug.log, you would want the error to appear atDebug.log, not on the whole function call
Relative path to the project gren.json to be used in an Error or expected test error.
Constant for "gren.json"
A single edit to be applied to a file's source in order to fix a check error
Insert something at the given position
Remove the section in between a given region
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.
Walk immediate child-expressions, collecting information on the way.
To additionally track locally introduced variables, use expressionImmediateSubsFoldWithIntroducedVariables
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.
Place where a local variable is introduced
Walk immediate child-patterns, collecting information on the way
Walk immediate child-patterns, collecting information on the way
Fold over all the bindings contained (deep) within
the given pattern.
Typically used with patternVariablesFold Dict.set currentLocalVariables pattern
Fold over all the bindings contained (deep) within
the given patterns.
Typically used with patternArrayVariablesFold Dict.set Dict.empty declarationParameters
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)
"[]"
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
Find all occurrences of a given section in the source in the when you can't access or easily calculate the region. Use sparingly
Convert an offset into the source (measured in units) into a region. Use sparingly
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.
The module names listed in the exposed-modules field in a package gren.json.
If you need a set, use outlineExposedModuleNameSet
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.todoetc 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.
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.
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)
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
A construct that can inspect your project files and report errors, see ExtraCheck.create
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" ]
, ...
}
Try to apply a set of SourceEdites to the relevant file source,
potentially failing with a SourceEditError
An undesired situation when trying to apply SourceEdits
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-checksbut 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.logis likely a nice enough alternative) - the ability to export the produced json for external use
(not a goal of
gren-extra-checksbut undeniably cool, similar toelm-reviewinsights/extract although less explicit) - quite a burden users should preferably not bear
- the ability to use the encoded cache at any time enables write to disk
(not a goal of
-
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.runusers 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!