elm-review/tests/NoUnoptimizedRecursion.elm
2022-12-08 16:27:43 +01:00

674 lines
23 KiB
Elm

module NoUnoptimizedRecursion exposing
( rule
, Configuration, optOutWithComment, optInWithComment
)
{-|
@docs rule
Tail-call optimization makes Elm code more performant and helps prevent stack overflows.
Since this optimization is done silently and under specific circumstances, it is unfortunately relatively easy
to not notice when the optimization is not being applied. You can find the [reasons why a function would not be optimized below](#fail).
I wrote a whole [article about tail-call optimization](https://jfmengels.net/tail-call-optimization/). Some of the information
are repeated in this rule's documentation, but it's more complete.
## Configuration
@docs Configuration, optOutWithComment, optInWithComment
## When (not) to enable this rule
This rule is useful for both application maintainers and package authors to detect locations where
performance could be improved and where stack overflows can happen.
You should not enable this rule if you currently do not want to invest your time into thinking about performance.
## Try it out
You can try this rule out by running the following command:
```bash
elm-review --template jfmengels/elm-review-performance/example --rules NoUnoptimizedRecursion
```
The rule uses `optOutWithComment "IGNORE TCO"` as its configuration.
## Success
This function won't be reported because it is tail-call optimized.
fun n =
if condition n then
fun (n - 1)
else
n
This function won't be reported because it has been tagged as ignored.
-- With opt-out configuration
config =
[ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optOutWithComment "IGNORE TCO")
]
fun n =
-- elm-review: IGNORE TCO
fun n * n
This function won't be reported because it has not been tagged.
-- With opt-in configuration
config =
[ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optInWithComment "ENSURE TCO")
]
fun n =
fun n * n
## Fail
To understand when a function would not get tail-call optimized, it is important to understand when it would be optimized.
The Elm compiler is able to apply tail-call optimization **only** when a recursive call **(1)** is a simple function application and **(2)** is the last operation that the function does in a branch.
**(1)** means that while `recurse n = recurse (n - 1)` would be optimized, `recurse n = recurse <| n - 1` would not. Even though you may consider `<|` and `|>` as syntactic sugar for function calls, the compiler doesn't (at least with regard to TCO).
As for **(2)**, the locations where a recursive call may happen are:
- branches of an if expression
- branches of a case expression
- in the body of a let expression
- inside simple parentheses
and only if each of the above appeared at the root of the function or in one of the above locations themselves.
The compiler optimizes every recursive call that adheres to the rules above, and simply doesn't optimize the other
branches which would call the function naively and add to the stack frame.
It is therefore possible to have **partially tail-call optimized functions**.
Following is a list of likely situations that will be reported.
### An operation is applied on the result of a function call
The result of this recursive call gets multiplied by `n`, making the recursive call not the last thing to happen in this branch.
factorial : Int -> Int
factorial n =
if n <= 1 then
1
else
factorial (n - 1) * n
Hint: When you need to apply an operation on the result of a recursive call, what you can do is to add an argument holding the result value and apply the operations on it instead.
factorialHelp : Int -> Int -> Int
factorialHelp n result =
if n <= 1 then
result
else
factorialHelp (result * n)
and split the function into the one that will do recursive calls (above) and an "API-facing" function which will set the initial result value (below).
factorial : Int -> Int
factorial n =
factorialHelp n 1
### Calls using the |> or <| operators
Even though you may consider these operators as syntactic sugar for function calls, the compiler doesn't and
the following won't be optimized. The compiler doesn't special-case these functions and considers them as operators just
like `(*)` in the example above.
fun n =
if condition n then
fun <| n - 1
else
n
fun n =
if condition n then
(n - 1)
|> fun
else
n
The fix here consists of converting the recursive calls to ones that don't use a pipe operator.
### Calls appearing in || or && conditions
The following won't be optimized.
isPrefixOf : List a -> List a -> Bool
isPrefixOf prefix list =
case ( prefix, list ) of
( [], _ ) ->
True
( _ :: _, [] ) ->
False
( p :: ps, x :: xs ) ->
p == x && isPrefixOf ps xs
The fix here is consists of using if expressions instead.
isPrefixOf : List a -> List a -> Bool
isPrefixOf prefix list =
case ( prefix, list ) of
( [], _ ) ->
True
( _ :: _, [] ) ->
False
( p :: ps, x :: xs ) ->
if p == x then
isPrefixOf ps xs
else
False
### Calls from let declarations
Calls from let functions won't be optimized.
fun n =
let
funHelp y =
fun (y - 1)
in
funHelp n
Note that recursive let functions can be optimized if they call themselves, but calling the parent function
will cause the parent to not be optimized.
-}
import Elm.Syntax.Declaration as Declaration exposing (Declaration)
import Elm.Syntax.Expression as Expression exposing (Expression)
import Elm.Syntax.Node as Node exposing (Node(..))
import Elm.Syntax.Range exposing (Range)
import Review.Rule as Rule exposing (Rule)
import Set exposing (Set)
{-| Reports recursive functions that are not [tail-call optimized](https://functional-programming-in-elm.netlify.app/recursion/tail-call-elimination.html).
-}
rule : Configuration -> Rule
rule configuration =
Rule.newModuleRuleSchema "NoUnoptimizedRecursion" initialContext
|> Rule.withCommentsVisitor (commentsVisitor configuration)
|> Rule.withDeclarationEnterVisitor (declarationVisitor configuration)
|> Rule.withExpressionEnterVisitor (expressionEnterVisitor configuration)
|> Rule.withExpressionExitVisitor expressionExitVisitor
|> Rule.fromModuleRuleSchema
-- CONFIGURATION
{-| Configuration for `NoUnoptimizedRecursion`.
Use [`optOutWithComment`](#optOutWithComment) or [`optInWithComment`](#optInWithComment) to configure this rule.
You can use comments to tag functions as to be checked or ignored, depending on the configuration option you chose.
This comment has to appear on the line after the `=` that follows the declaration of your function. Note that this
comment only needs to contain the tag that you're choosing and that it is case-sensitive.
The same will apply for functions defined in a let expression, since they can be tail-call optimized as well.
-}
type Configuration
= OptOut String
| OptIn String
{-| Reports recursive functions by default, opt out functions tagged with a comment.
config =
[ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optOutWithComment "IGNORE TCO")
]
With the configuration above, the following function would not be reported.
fun n =
-- elm-review: IGNORE TCO
if condition n then
fun n * n
else
n
The reasons for allowing to opt-out is because sometimes recursive functions are simply not translatable to
tail-call optimized ones, for instance the ones that need to recurse over multiple elements (`fun left + fun right`).
I recommend to **not** default to ignoring a reported issue, and instead to discuss with your colleagues how to best
solve the error when you encounter it or when you see them ignore an error.
I recommend to use this configuration option as your permanent configuration once you have fixed or opted-out of every function.
-}
optOutWithComment : String -> Configuration
optOutWithComment comment =
OptOut comment
{-| Reports only the functions tagged with a comment.
config =
[ NoUnoptimizedRecursion.rule (NoUnoptimizedRecursion.optInWithComment "ENSURE TCO")
]
With the configuration above, the following function would be reported.
fun n =
-- ENSURE TCO
if condition n then
fun n * n
else
n
-}
optInWithComment : String -> Configuration
optInWithComment comment =
OptIn comment
hasNoArguments : Expression.FunctionImplementation -> Bool
hasNoArguments declaration =
List.isEmpty declaration.arguments
shouldReportFunction : Configuration -> Context -> Node Expression.FunctionImplementation -> Bool
shouldReportFunction configuration context (Node range declaration) =
if hasNoArguments declaration then
False
else
let
foundComment : Bool
foundComment =
Set.member (range.start.row + 1) context.comments
in
case configuration of
OptOut _ ->
not foundComment
OptIn _ ->
foundComment
-- CONTEXT
type alias Context =
{ currentFunctionName : String
, tcoLocations : List Range
, newScopesForLet : List ( Range, String )
, parentScopes : List ( Range, Scope )
, parentNames : Set String
, comments : Set Int
, deOptimizationRange : Maybe Range
, deOptimizationReason : List String
}
type alias Scope =
{ currentFunctionName : String
, tcoLocations : List Range
, newScopes : List ( Range, String )
}
initialContext : Context
initialContext =
{ currentFunctionName = ""
, tcoLocations = []
, newScopesForLet = []
, parentScopes = []
, parentNames = Set.empty
, comments = Set.empty
, deOptimizationRange = Nothing
, deOptimizationReason = []
}
-- VISITORS
commentsVisitor : Configuration -> List (Node String) -> Context -> ( List nothing, Context )
commentsVisitor configuration comments context =
let
commentTag : String
commentTag =
case configuration of
OptOut commentTag_ ->
commentTag_
OptIn commentTag_ ->
commentTag_
in
( []
, { context
| comments =
List.foldl
(\(Node range value) acc ->
if String.contains commentTag value then
Set.insert range.start.row acc
else
acc
)
Set.empty
comments
}
)
declarationVisitor : Configuration -> Node Declaration -> Context -> ( List nothing, Context )
declarationVisitor configuration node context =
case Node.value node of
Declaration.FunctionDeclaration function ->
( []
, { currentFunctionName =
if shouldReportFunction configuration context function.declaration then
function.declaration
|> Node.value
|> .name
|> Node.value
else
""
, tcoLocations =
[ function.declaration
|> Node.value
|> .expression
|> Node.range
]
, newScopesForLet = []
, parentScopes = []
, parentNames = Set.empty
, comments = context.comments
, deOptimizationRange = Nothing
, deOptimizationReason = []
}
)
_ ->
( [], context )
expressionEnterVisitor : Configuration -> Node Expression -> Context -> ( List (Rule.Error {}), Context )
expressionEnterVisitor configuration node context =
let
newContext : Context
newContext =
case context.newScopesForLet of
[] ->
context
( range, name ) :: restOfNewScopes ->
if range == Node.range node then
{ currentFunctionName = name
, tcoLocations = [ range ]
, newScopesForLet = restOfNewScopes
, parentScopes =
( range
, { currentFunctionName = context.currentFunctionName, tcoLocations = context.tcoLocations, newScopes = restOfNewScopes }
)
:: context.parentScopes
, parentNames = Set.insert context.currentFunctionName context.parentNames
, comments = context.comments
, deOptimizationRange = context.deOptimizationRange
, deOptimizationReason = context.deOptimizationReason
}
else
context
in
if isInTcoLocation newContext (Node.range node) then
( reportReferencesToParentFunctions node newContext, addAllowedLocation configuration node newContext )
else
( reportRecursiveCallInNonAllowedLocation node newContext, newContext )
reportRecursiveCallInNonAllowedLocation : Node Expression -> Context -> List (Rule.Error {})
reportRecursiveCallInNonAllowedLocation node context =
case Node.value node of
Expression.FunctionOrValue [] name ->
if name == context.currentFunctionName then
[ error (Node.range node) context.deOptimizationReason ]
else
[]
_ ->
[]
reportReferencesToParentFunctions : Node Expression -> Context -> List (Rule.Error {})
reportReferencesToParentFunctions node context =
case Node.value node of
Expression.Application ((Node funcRange (Expression.FunctionOrValue [] name)) :: _) ->
if Set.member name context.parentNames then
[ error funcRange [ "Among other possible reasons, the recursive call should not appear inside a let declaration." ] ]
else
[]
_ ->
[]
error : Range -> List String -> Rule.Error {}
error range additionalDetails =
Rule.error
{ message = "This function call cannot be tail-call optimized"
, details =
additionalDetails
++ [ "You can read more about why over at https://package.elm-lang.org/packages/jfmengels/elm-review-performance/latest/NoUnoptimizedRecursion#fail" ]
}
range
addAllowedLocation : Configuration -> Node Expression -> Context -> Context
addAllowedLocation configuration node context =
case Node.value node of
Expression.Application (function :: _) ->
{ context
| tcoLocations = Node.range function :: context.tcoLocations
, deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are applying operations on the result of recursive call. The recursive call should be the last thing to happen in this branch." ]
}
Expression.IfBlock condition thenBranch elseBranch ->
{ context
| tcoLocations = Node.range thenBranch :: Node.range elseBranch :: context.tcoLocations
, deOptimizationRange = Just (Node.range condition)
, deOptimizationReason = [ "Among other possible reasons, the recursive call should not appear inside an if condition." ]
}
Expression.LetExpression { declarations, expression } ->
addAllowedLocationForLetExpression configuration context declarations expression
Expression.ParenthesizedExpression expr ->
{- The following translates to TCO code
fun x =
(fun x)
-}
{ context | tcoLocations = Node.range expr :: context.tcoLocations }
Expression.CaseExpression { cases } ->
let
tcoLocations : List Range
tcoLocations =
List.foldl (\( _, Node range _ ) acc -> range :: acc) context.tcoLocations cases
in
{ context
| tcoLocations = tcoLocations
, deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, the recursive call should not appear in the pattern to evaluate for a case expression." ]
}
Expression.OperatorApplication operator _ _ _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason =
List.filterMap identity
[ Just "Among other possible reasons, you are applying operations on the result of recursive call. The recursive call should be the last thing to happen in this branch."
, if operator == "<|" || operator == "|>" then
Just ("Removing the usage of `" ++ operator ++ "` may fix the issue here.")
else
Nothing
]
}
Expression.Negation _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are applying operations on the result of recursive call. The recursive call should be the last thing to happen in this branch." ]
}
Expression.TupledExpression _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are storing the result of recursive call inside a tuple. The recursive call should be the last thing to happen in this branch." ]
}
Expression.ListExpr _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are storing the result of recursive call inside a list. The recursive call should be the last thing to happen in this branch." ]
}
Expression.RecordExpr _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are storing the result of recursive call inside a record. The recursive call should be the last thing to happen in this branch." ]
}
Expression.RecordUpdateExpression _ _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are storing the result of recursive call inside a record. The recursive call should be the last thing to happen in this branch." ]
}
Expression.RecordAccess _ _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, you are accessing a field on the result of recursive call. The recursive call should be the last thing to happen in this branch." ]
}
Expression.LambdaExpression _ ->
{ context
| deOptimizationRange = Just (Node.range node)
, deOptimizationReason = [ "Among other possible reasons, the recursive call should not appear inside an anonymous function." ]
}
_ ->
context
expressionExitVisitor : Node Expression -> Context -> ( List nothing, Context )
expressionExitVisitor node context =
case context.parentScopes of
[] ->
( [], removeDeOptimizationRangeIfNeeded node context )
( headRange, headScope ) :: restOfParentScopes ->
if headRange == Node.range node then
( []
, removeDeOptimizationRangeIfNeeded node
{ currentFunctionName = headScope.currentFunctionName
, tcoLocations = headScope.tcoLocations
, newScopesForLet = headScope.newScopes
, parentScopes = restOfParentScopes
, parentNames = Set.remove headScope.currentFunctionName context.parentNames
, comments = context.comments
, deOptimizationRange = context.deOptimizationRange
, deOptimizationReason = context.deOptimizationReason
}
)
else
( [], removeDeOptimizationRangeIfNeeded node context )
addAllowedLocationForLetExpression : Configuration -> Context -> List (Node Expression.LetDeclaration) -> Node a -> Context
addAllowedLocationForLetExpression configuration context declarations expression =
let
newScopes : List ( Range, String )
newScopes =
List.filterMap
(\decl ->
case Node.value decl of
Expression.LetFunction function ->
let
functionDeclaration : Expression.FunctionImplementation
functionDeclaration =
Node.value function.declaration
in
Just
( Node.range functionDeclaration.expression
, if shouldReportFunction configuration context function.declaration then
Node.value functionDeclaration.name
else
""
)
Expression.LetDestructuring _ _ ->
Nothing
)
declarations
in
{ context
| newScopesForLet = newScopes
{- The following translates to TCO code
let
fun x =
fun x
in
fun 1
-}
, tcoLocations = Node.range expression :: context.tcoLocations
}
removeDeOptimizationRangeIfNeeded : Node Expression -> Context -> Context
removeDeOptimizationRangeIfNeeded node context =
if Just (Node.range node) == context.deOptimizationRange then
{ context | deOptimizationRange = Nothing }
else
context
isInTcoLocation : Context -> Range -> Bool
isInTcoLocation context range =
List.member range context.tcoLocations