module NoUnused.Exports exposing (rule) {-| Forbid the use of exposed elements that are never used in your project. @docs rule -} -- TODO Don't report type or type aliases (still `A(..)` though) if they are -- used in exposed function arguments/return values. import Dict exposing (Dict) import Elm.Module import Elm.Project import Elm.Syntax.Declaration as Declaration exposing (Declaration) import Elm.Syntax.Exposing as Exposing exposing (Exposing, TopLevelExpose) import Elm.Syntax.Expression as Expression exposing (Expression) import Elm.Syntax.Import exposing (Import) import Elm.Syntax.Module as Module import Elm.Syntax.ModuleName exposing (ModuleName) import Elm.Syntax.Node as Node exposing (Node(..)) import Elm.Syntax.Pattern as Pattern exposing (Pattern) import Elm.Syntax.Range as Range exposing (Range) import Elm.Syntax.TypeAnnotation as TypeAnnotation exposing (TypeAnnotation) import List.Extra import NoUnused.LamderaSupport as LamderaSupport import Review.Fix as Fix import Review.ModuleNameLookupTable as ModuleNameLookupTable exposing (ModuleNameLookupTable) import Review.Rule as Rule exposing (Error, Rule) import Set exposing (Set) {-| Report functions and types that are exposed from a module but that are never used in other modules. Also reports when a module is entirely unused. 🔧 Running with `--fix` will automatically remove all the reported errors. It won't automatically remove unused modules though. If the project is a package and the module that declared the element is exposed, then nothing will be reported. config = [ NoUnused.Exports.rule ] ## Try it out You can try this rule out by running the following command: ```bash elm-review --template jfmengels/elm-review-unused/example --rules NoUnused.Exports ``` -} rule : Rule rule = Rule.newProjectRuleSchema "NoUnused.Exports" initialProjectContext |> Rule.withModuleVisitor moduleVisitor |> Rule.withModuleContextUsingContextCreator { fromProjectToModule = fromProjectToModule , fromModuleToProject = fromModuleToProject , foldProjectContexts = foldProjectContexts } |> Rule.withElmJsonProjectVisitor (\elmJson context -> ( [], elmJsonVisitor elmJson context )) |> Rule.withFinalProjectEvaluation finalEvaluationForProject |> Rule.providesFixesForProjectRule |> Rule.fromProjectRuleSchema moduleVisitor : Rule.ModuleRuleSchema {} ModuleContext -> Rule.ModuleRuleSchema { hasAtLeastOneVisitor : () } ModuleContext moduleVisitor schema = schema |> Rule.withImportVisitor (\node context -> ( [], importVisitor node context )) |> Rule.withDeclarationEnterVisitor (\node context -> ( [], declarationVisitor node context )) |> Rule.withExpressionEnterVisitor (\node context -> ( [], expressionVisitor node context )) -- CONTEXT type alias ProjectContext = { projectType : ProjectType , modules : Dict ModuleName { moduleKey : Rule.ModuleKey , exposed : Dict String ExposedElement , moduleNameLocation : Range } , usedModules : Set ModuleName , used : Set ( ModuleName, String ) , constructors : Dict ( ModuleName, String ) String } type alias ExposedElement = { range : Range , rangesToRemove : List Range , elementType : ExposedElementType } type ProjectType = IsApplication ElmApplicationType | IsPackage (Set (List String)) type ElmApplicationType = ElmApplication | LamderaApplication type ExposedElementType = Function | TypeOrTypeAlias | ExposedType (List String) type alias ModuleContext = { lookupTable : ModuleNameLookupTable , exposed : Dict String ExposedElement , used : Set ( ModuleName, String ) , elementsNotToReport : Set String , importedModules : Set ModuleName , containsMainFunction : Bool , projectType : ProjectType } initialProjectContext : ProjectContext initialProjectContext = { projectType = IsApplication ElmApplication , modules = Dict.empty , usedModules = Set.singleton [ "ReviewConfig" ] , used = Set.empty , constructors = Dict.empty } fromProjectToModule : Rule.ContextCreator ProjectContext ModuleContext fromProjectToModule = Rule.initContextCreator (\lookupTable ast moduleDocumentation projectContext -> let exposed : Dict String ExposedElement exposed = case Module.exposingList (Node.value ast.moduleDefinition) of Exposing.All _ -> Dict.empty Exposing.Explicit explicitlyExposed -> collectExposedElements moduleDocumentation explicitlyExposed ast.declarations in { lookupTable = lookupTable , exposed = exposed , used = Set.empty , elementsNotToReport = Set.empty , importedModules = Set.empty , containsMainFunction = False , projectType = projectContext.projectType } ) |> Rule.withModuleNameLookupTable |> Rule.withFullAst |> Rule.withModuleDocumentation fromModuleToProject : Rule.ContextCreator ModuleContext ProjectContext fromModuleToProject = Rule.initContextCreator (\moduleKey (Node moduleNameRange moduleName) moduleContext -> { projectType = IsApplication ElmApplication , modules = Dict.singleton moduleName { moduleKey = moduleKey , exposed = moduleContext.exposed , moduleNameLocation = moduleNameRange } , used = Set.foldl (\element acc -> Set.insert ( moduleName, element ) acc) moduleContext.used moduleContext.elementsNotToReport , usedModules = if Set.member [ "Test" ] moduleContext.importedModules || moduleContext.containsMainFunction then Set.insert moduleName moduleContext.importedModules else moduleContext.importedModules , constructors = Dict.foldl (\name element acc -> case element.elementType of ExposedType constructorNames -> List.foldl (\constructorName listAcc -> Dict.insert ( moduleName, constructorName ) name listAcc) acc constructorNames _ -> acc ) Dict.empty moduleContext.exposed } ) |> Rule.withModuleKey |> Rule.withModuleNameNode foldProjectContexts : ProjectContext -> ProjectContext -> ProjectContext foldProjectContexts newContext previousContext = { projectType = previousContext.projectType , modules = Dict.union newContext.modules previousContext.modules , usedModules = Set.union newContext.usedModules previousContext.usedModules , used = Set.union newContext.used previousContext.used , constructors = Dict.union newContext.constructors previousContext.constructors } registerAsUsed : ( ModuleName, String ) -> ModuleContext -> ModuleContext registerAsUsed ( moduleName, name ) moduleContext = { moduleContext | used = Set.insert ( moduleName, name ) moduleContext.used } -- ELM JSON VISITOR elmJsonVisitor : Maybe { a | project : Elm.Project.Project } -> ProjectContext -> ProjectContext elmJsonVisitor maybeProject projectContext = case maybeProject |> Maybe.map .project of Just (Elm.Project.Package { exposed }) -> let exposedModuleNames : List Elm.Module.Name exposedModuleNames = case exposed of Elm.Project.ExposedList names -> names Elm.Project.ExposedDict fakeDict -> List.concatMap Tuple.second fakeDict in { projectContext | projectType = exposedModuleNames |> List.foldr (\moduleName acc -> Set.insert (Elm.Module.toString moduleName |> String.split ".") acc ) Set.empty |> IsPackage } Just (Elm.Project.Application { depsDirect }) -> let elmApplicationType : ElmApplicationType elmApplicationType = if LamderaSupport.isLamderaApplication depsDirect then LamderaApplication else ElmApplication in { projectContext | projectType = IsApplication elmApplicationType } Nothing -> { projectContext | projectType = IsApplication ElmApplication } -- PROJECT EVALUATION finalEvaluationForProject : ProjectContext -> List (Error { useErrorForModule : () }) finalEvaluationForProject projectContext = let used : Set ( ModuleName, String ) used = Set.foldl (\(( moduleName, _ ) as key) acc -> case Dict.get key projectContext.constructors of Just typeName -> Set.insert ( moduleName, typeName ) acc Nothing -> acc ) projectContext.used projectContext.used ( usedModules, unusedModules ) = projectContext.modules |> removeExposedPackages projectContext |> Dict.partition (\moduleName _ -> Set.member moduleName projectContext.usedModules) in List.concat [ usedModules |> Dict.toList |> List.concatMap (errorsForModule projectContext used) , unusedModules |> Dict.toList |> List.map unusedModuleError ] unusedModuleError : ( ModuleName, { a | moduleKey : Rule.ModuleKey, moduleNameLocation : Range } ) -> Error scope unusedModuleError ( moduleName, { moduleKey, moduleNameLocation } ) = Rule.errorForModule moduleKey { message = "Module `" ++ String.join "." moduleName ++ "` is never used." , details = [ "This module is never used. You may want to remove it to keep your project clean, and maybe detect some unused code in your project." ] } moduleNameLocation errorsForModule : ProjectContext -> Set ( ModuleName, String ) -> ( ModuleName, { a | moduleKey : Rule.ModuleKey, exposed : Dict String ExposedElement } ) -> List (Error scope) errorsForModule projectContext used ( moduleName, { moduleKey, exposed } ) = exposed |> removeApplicationExceptions projectContext |> removeReviewConfig moduleName |> Dict.filter (\name _ -> not <| Set.member ( moduleName, name ) used) |> Dict.toList |> List.concatMap (\( name, element ) -> let what : String what = case element.elementType of Function -> "Exposed function or value" TypeOrTypeAlias -> "Exposed type or type alias" ExposedType _ -> "Exposed type" in [ Rule.errorForModuleWithFix moduleKey { message = what ++ " `" ++ name ++ "` is never used outside this module." , details = [ "This exposed element is never used. You may want to remove it to keep your project clean, and maybe detect some unused code in your project." ] } element.range (List.map Fix.removeRange element.rangesToRemove) ] ) removeExposedPackages : ProjectContext -> Dict ModuleName a -> Dict ModuleName a removeExposedPackages projectContext dict = case projectContext.projectType of IsApplication _ -> dict IsPackage exposedModuleNames -> Dict.filter (\name _ -> not <| Set.member name exposedModuleNames) dict removeApplicationExceptions : ProjectContext -> Dict String a -> Dict String a removeApplicationExceptions projectContext dict = case projectContext.projectType of IsPackage _ -> dict IsApplication ElmApplication -> Dict.remove "main" dict IsApplication LamderaApplication -> dict |> Dict.remove "main" |> Dict.remove "app" removeReviewConfig : ModuleName -> Dict String a -> Dict String a removeReviewConfig moduleName dict = if moduleName == [ "ReviewConfig" ] then Dict.remove "config" dict else dict getRangesToRemove : List ( Int, String ) -> Bool -> String -> Int -> Maybe Range -> Range -> Range -> List Range getRangesToRemove comments canRemoveExposed name index maybePreviousRange range nextRange = if canRemoveExposed then let exposeRemoval : Range exposeRemoval = if index == 0 then { range | end = nextRange.start } else case maybePreviousRange of Nothing -> range Just previousRange -> { range | start = previousRange.end } in List.filterMap identity [ Just exposeRemoval , findMap (findDocsRangeToRemove name) comments ] else [] findDocsRangeToRemove : String -> ( Int, String ) -> Maybe Range findDocsRangeToRemove name fullComment = case findCommentInMiddle name fullComment of Just range -> Just range Nothing -> findCommentAtEnd name fullComment findCommentInMiddle : String -> ( Int, String ) -> Maybe Range findCommentInMiddle name ( row, comment ) = String.indexes (" " ++ name ++ ", ") comment |> List.head |> Maybe.map (\index -> { start = { row = row, column = index + 2 } , end = { row = row, column = index + String.length name + 4 } } ) findCommentAtEnd : String -> ( Int, String ) -> Maybe Range findCommentAtEnd name ( row, comment ) = if comment == "@docs " ++ name then Just { start = { row = row, column = 1 } , end = { row = row + 1, column = 1 } } else String.indexes (", " ++ name) comment |> List.head |> Maybe.map (\index -> { start = { row = row, column = index + 1 } , end = { row = row, column = index + String.length name + 3 } } ) findMap : (a -> Maybe b) -> List a -> Maybe b findMap mapper list = case list of [] -> Nothing first :: rest -> case mapper first of Just value -> Just value Nothing -> findMap mapper rest untilEndOfVariable : String -> Range -> Range untilEndOfVariable name range = if range.start.row == range.end.row then range else { range | end = { row = range.start.row, column = range.start.column + String.length name } } -- IMPORT VISITOR importVisitor : Node Import -> ModuleContext -> ModuleContext importVisitor (Node _ import_) moduleContext = let moduleName : ModuleName moduleName = Node.value import_.moduleName in { moduleContext | used = collectUsedFromImport moduleName import_.exposingList moduleContext.used , importedModules = Set.insert moduleName moduleContext.importedModules } collectUsedFromImport : ModuleName -> Maybe (Node Exposing) -> Set ( ModuleName, String ) -> Set ( ModuleName, String ) collectUsedFromImport moduleName exposingList used = case Maybe.map Node.value exposingList of Just (Exposing.Explicit list) -> List.foldl (\(Node _ element) acc -> case element of Exposing.FunctionExpose name -> Set.insert ( moduleName, name ) acc Exposing.TypeOrAliasExpose name -> Set.insert ( moduleName, name ) acc Exposing.TypeExpose { name } -> Set.insert ( moduleName, name ) acc Exposing.InfixExpose _ -> acc ) used list Just (Exposing.All _) -> used Nothing -> used collectDocsReferences : Maybe (Node String) -> List ( Int, String ) collectDocsReferences maybeModuleDocumentation = case maybeModuleDocumentation of Just (Node range moduleDocumentation) -> let lines : List String lines = moduleDocumentation |> String.lines |> List.drop 1 in List.Extra.indexedFilterMap (\lineNumber line -> if String.startsWith "@docs " line then Just ( lineNumber, line ) else Nothing ) (range.start.row + 1) lines [] Nothing -> [] collectExposedElements : Maybe (Node String) -> List (Node Exposing.TopLevelExpose) -> List (Node Declaration) -> Dict String ExposedElement collectExposedElements moduleDocumentation exposingNodes declarations = let docsReferences : List ( Int, String ) docsReferences = collectDocsReferences moduleDocumentation declaredNames : Set String declaredNames = List.foldl (\(Node _ declaration) acc -> case declarationName declaration of Just name -> Set.insert name acc Nothing -> acc ) Set.empty declarations in collectExposedElementsHelp docsReferences declarations declaredNames (List.length exposingNodes /= 1) Nothing exposingNodes 0 Dict.empty collectExposedElementsHelp : List ( Int, String ) -> List (Node Declaration) -> Set String -> Bool -> Maybe Range -> List (Node TopLevelExpose) -> Int -> Dict String ExposedElement -> Dict String ExposedElement collectExposedElementsHelp docsReferences declarations declaredNames canRemoveExposed maybePreviousRange exposingNodes index acc = case exposingNodes of [] -> acc (Node range value) :: rest -> let nextRange : Range nextRange = case List.head rest of Just nextNode -> Node.range nextNode Nothing -> Range.emptyRange newAcc : Dict String ExposedElement newAcc = case value of Exposing.FunctionExpose name -> if Set.member name declaredNames then Dict.insert name { range = untilEndOfVariable name range , rangesToRemove = getRangesToRemove docsReferences canRemoveExposed name index maybePreviousRange range nextRange , elementType = Function } acc else acc Exposing.TypeOrAliasExpose name -> if Set.member name declaredNames then Dict.insert name { range = untilEndOfVariable name range , rangesToRemove = getRangesToRemove docsReferences canRemoveExposed name index maybePreviousRange range nextRange , elementType = TypeOrTypeAlias } acc else acc Exposing.TypeExpose { name } -> if Set.member name declaredNames then Dict.insert name { range = untilEndOfVariable name range , rangesToRemove = [] , elementType = ExposedType (findConstructorsForExposedCustomType name declarations) } acc else acc Exposing.InfixExpose _ -> acc in collectExposedElementsHelp docsReferences declarations declaredNames canRemoveExposed (Just range) rest (index + 1) newAcc declarationVisitor : Node Declaration -> ModuleContext -> ModuleContext declarationVisitor node moduleContext = let ( allUsedTypes, comesFromCustomTypeWithHiddenConstructors ) = typesUsedInDeclaration moduleContext node elementsNotToReport : Set String elementsNotToReport = (if comesFromCustomTypeWithHiddenConstructors then moduleContext.elementsNotToReport else List.foldl (\( _, name ) acc -> Set.insert name acc) moduleContext.elementsNotToReport allUsedTypes ) |> maybeSetInsert (testFunctionName moduleContext node) used : Set ( ModuleName, String ) used = List.foldl Set.insert moduleContext.used allUsedTypes in { moduleContext | elementsNotToReport = elementsNotToReport , used = used , containsMainFunction = moduleContext.containsMainFunction || doesModuleContainMainFunction moduleContext.projectType node } doesModuleContainMainFunction : ProjectType -> Node Declaration -> Bool doesModuleContainMainFunction projectType declaration = case projectType of IsPackage _ -> False IsApplication elmApplicationType -> case Node.value declaration of Declaration.FunctionDeclaration function -> isMainFunction elmApplicationType (function.declaration |> Node.value |> .name |> Node.value) _ -> False isMainFunction : ElmApplicationType -> String -> Bool isMainFunction elmApplicationType name = case elmApplicationType of ElmApplication -> name == "main" LamderaApplication -> name == "main" || name == "app" maybeSetInsert : Maybe comparable -> Set comparable -> Set comparable maybeSetInsert maybeValue set = case maybeValue of Just value -> Set.insert value set Nothing -> set findConstructorsForExposedCustomType : String -> List (Node Declaration) -> List String findConstructorsForExposedCustomType typeName declarations = findMap (\node -> case Node.value node of Declaration.CustomTypeDeclaration type_ -> if Node.value type_.name /= typeName then Nothing else List.map (\c -> c |> Node.value |> .name |> Node.value) type_.constructors |> Just _ -> Nothing ) declarations |> Maybe.withDefault [] declarationName : Declaration -> Maybe String declarationName declaration = case declaration of Declaration.FunctionDeclaration function -> function.declaration |> Node.value |> .name |> Node.value |> Just Declaration.CustomTypeDeclaration type_ -> Just <| Node.value type_.name Declaration.AliasDeclaration alias_ -> Just <| Node.value alias_.name Declaration.PortDeclaration port_ -> Just <| Node.value port_.name Declaration.InfixDeclaration { operator } -> Just <| Node.value operator Declaration.Destructuring _ _ -> Nothing testFunctionName : ModuleContext -> Node Declaration -> Maybe String testFunctionName moduleContext node = case Node.value node of Declaration.FunctionDeclaration function -> case Maybe.map (\(Node _ value) -> Node.value value.typeAnnotation) function.signature of Just (TypeAnnotation.Typed typeNode _) -> if (Tuple.second (Node.value typeNode) == "Test") && (ModuleNameLookupTable.moduleNameFor moduleContext.lookupTable typeNode == Just [ "Test" ]) then function.declaration |> Node.value |> .name |> Node.value |> Just else Nothing _ -> Nothing _ -> Nothing typesUsedInDeclaration : ModuleContext -> Node Declaration -> ( List ( ModuleName, String ), Bool ) typesUsedInDeclaration moduleContext declaration = case Node.value declaration of Declaration.FunctionDeclaration function -> ( case function.signature of Just signature -> [] |> collectTypesFromTypeAnnotation moduleContext [ (Node.value signature).typeAnnotation ] |> findUsedConstructors moduleContext.lookupTable (Node.value function.declaration).arguments Nothing -> findUsedConstructors moduleContext.lookupTable (Node.value function.declaration).arguments [] , False ) Declaration.CustomTypeDeclaration type_ -> let typesUsedInArguments : List ( ModuleName, String ) typesUsedInArguments = List.foldl (\constructor acc -> collectTypesFromTypeAnnotation moduleContext (Node.value constructor).arguments acc) [] type_.constructors in ( typesUsedInArguments , case Dict.get (Node.value type_.name) moduleContext.exposed |> Maybe.map .elementType of Just (ExposedType _) -> False _ -> True ) Declaration.AliasDeclaration alias_ -> ( collectTypesFromTypeAnnotation moduleContext [ alias_.typeAnnotation ] [], False ) Declaration.PortDeclaration signature -> ( collectTypesFromTypeAnnotation moduleContext [ signature.typeAnnotation ] [], False ) Declaration.InfixDeclaration _ -> ( [], False ) Declaration.Destructuring _ _ -> ( [], False ) collectTypesFromTypeAnnotation : ModuleContext -> List (Node TypeAnnotation) -> List ( ModuleName, String ) -> List ( ModuleName, String ) collectTypesFromTypeAnnotation moduleContext nodes acc = case nodes of [] -> acc node :: restOfNodes -> case Node.value node of TypeAnnotation.FunctionTypeAnnotation left right -> collectTypesFromTypeAnnotation moduleContext (left :: right :: restOfNodes) acc TypeAnnotation.Typed (Node range ( _, name )) params -> case ModuleNameLookupTable.moduleNameAt moduleContext.lookupTable range of Just moduleName -> collectTypesFromTypeAnnotation moduleContext (params ++ restOfNodes) (( moduleName, name ) :: acc) Nothing -> collectTypesFromTypeAnnotation moduleContext (params ++ restOfNodes) acc TypeAnnotation.Record fields -> let subNodes : List (Node TypeAnnotation) subNodes = List.map (\(Node _ ( _, value )) -> value) fields in collectTypesFromTypeAnnotation moduleContext (subNodes ++ restOfNodes) acc TypeAnnotation.GenericRecord _ (Node _ fields) -> let subNodes : List (Node TypeAnnotation) subNodes = List.map (\(Node _ ( _, value )) -> value) fields in collectTypesFromTypeAnnotation moduleContext (subNodes ++ restOfNodes) acc TypeAnnotation.Tupled list -> collectTypesFromTypeAnnotation moduleContext (list ++ restOfNodes) acc _ -> collectTypesFromTypeAnnotation moduleContext restOfNodes acc -- EXPRESSION VISITOR expressionVisitor : Node Expression -> ModuleContext -> ModuleContext expressionVisitor node moduleContext = case Node.value node of Expression.FunctionOrValue _ name -> case ModuleNameLookupTable.moduleNameFor moduleContext.lookupTable node of Just moduleName -> registerAsUsed ( moduleName, name ) moduleContext Nothing -> moduleContext Expression.RecordUpdateExpression (Node range name) _ -> case ModuleNameLookupTable.moduleNameAt moduleContext.lookupTable range of Just moduleName -> registerAsUsed ( moduleName, name ) moduleContext Nothing -> moduleContext Expression.LetExpression { declarations } -> let used : List ( ModuleName, String ) used = List.foldl (\declaration acc -> case Node.value declaration of Expression.LetFunction function -> case function.signature of Just signature -> acc |> collectTypesFromTypeAnnotation moduleContext [ (Node.value signature).typeAnnotation ] |> findUsedConstructors moduleContext.lookupTable (Node.value function.declaration).arguments Nothing -> findUsedConstructors moduleContext.lookupTable (Node.value function.declaration).arguments acc Expression.LetDestructuring pattern _ -> findUsedConstructors moduleContext.lookupTable [ pattern ] acc ) [] declarations in { moduleContext | used = List.foldl Set.insert moduleContext.used used } Expression.CaseExpression { cases } -> let usedConstructors : List ( ModuleName, String ) usedConstructors = findUsedConstructors moduleContext.lookupTable (List.map Tuple.first cases) [] in { moduleContext | used = List.foldl Set.insert moduleContext.used usedConstructors } _ -> moduleContext findUsedConstructors : ModuleNameLookupTable -> List (Node Pattern) -> List ( ModuleName, String ) -> List ( ModuleName, String ) findUsedConstructors lookupTable patterns acc = case patterns of [] -> acc pattern :: restOfPatterns -> case Node.value pattern of Pattern.NamedPattern qualifiedNameRef newPatterns -> let newAcc : List ( ModuleName, String ) newAcc = case ModuleNameLookupTable.moduleNameFor lookupTable pattern of Just moduleName -> ( moduleName, qualifiedNameRef.name ) :: acc Nothing -> acc in findUsedConstructors lookupTable (newPatterns ++ restOfPatterns) newAcc Pattern.TuplePattern newPatterns -> findUsedConstructors lookupTable (newPatterns ++ restOfPatterns) acc Pattern.UnConsPattern left right -> findUsedConstructors lookupTable (left :: right :: restOfPatterns) acc Pattern.ListPattern newPatterns -> findUsedConstructors lookupTable (newPatterns ++ restOfPatterns) acc Pattern.AsPattern node _ -> findUsedConstructors lookupTable (node :: restOfPatterns) acc Pattern.ParenthesizedPattern node -> findUsedConstructors lookupTable (node :: restOfPatterns) acc _ -> findUsedConstructors lookupTable restOfPatterns acc