Created
March 2, 2026 13:24
-
-
Save pete-murphy/4a23c4aa8f2d1b4678ea81614709791c to your computer and use it in GitHub Desktop.
An elm-review rule for removing unused translations from JSON files (to be used with travelm-agency).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| module NoUnusedTranslations exposing (..) | |
| import Dict exposing (Dict) | |
| import Elm.Syntax.Declaration exposing (Declaration(..)) | |
| import Elm.Syntax.Expression as Expression exposing (Expression) | |
| import Elm.Syntax.Node as Node exposing (Node) | |
| import FastSet | |
| import Json.Decode | |
| import List.Extra | |
| import Review.Fix as Fix | |
| import Review.ModuleNameLookupTable as ModuleNameLookupTable exposing (ModuleNameLookupTable) | |
| import Review.Rule as Rule exposing (Rule) | |
| import String.Extra | |
| import Translations | |
| rule : Rule | |
| rule = | |
| let | |
| initialContext : ProjectContext | |
| initialContext = | |
| { translationFiles = Dict.empty | |
| , referencedTranslations = FastSet.empty | |
| } | |
| in | |
| Rule.newProjectRuleSchema "NoUnusedTranslations" initialContext | |
| |> Rule.withExtraFilesProjectVisitor staticFilesVisitor | |
| Translations.pattern | |
| |> Rule.withModuleVisitor moduleVisitor | |
| |> Rule.withModuleContextUsingContextCreator | |
| { fromProjectToModule = fromProjectToModule | |
| , fromModuleToProject = fromModuleToProject | |
| , foldProjectContexts = foldProjectContexts | |
| } | |
| |> Rule.withFinalProjectEvaluation finalProjectEvaluation | |
| |> Rule.fromProjectRuleSchema | |
| type alias ProjectContext = | |
| { translationFiles : | |
| Dict | |
| String | |
| { fileKey : Rule.ExtraFileKey | |
| , content : String | |
| , translations : Dict String String | |
| } | |
| , referencedTranslations : FastSet.Set String | |
| } | |
| type alias ModuleContext = | |
| { moduleNameLookupTable : ModuleNameLookupTable | |
| , referencedTranslations : FastSet.Set String | |
| } | |
| staticFilesVisitor : | |
| Dict String { fileKey : Rule.ExtraFileKey, content : String } | |
| -> ProjectContext | |
| -> ( List (Rule.Error { useErrorForModule : () }), ProjectContext ) | |
| staticFilesVisitor files context = | |
| let | |
| { errors, decodedFiles } = | |
| files | |
| |> Dict.foldl | |
| (\path { fileKey, content } acc -> | |
| case Json.Decode.decodeString (Json.Decode.dict Json.Decode.string) content of | |
| Ok translations -> | |
| { acc | |
| | decodedFiles = | |
| Dict.insert path | |
| { fileKey = fileKey | |
| , content = content | |
| , translations = translations | |
| } | |
| acc.decodedFiles | |
| } | |
| Err error -> | |
| { acc | |
| | errors = | |
| Rule.errorForExtraFile fileKey | |
| { message = "Invalid JSON" | |
| , details = [ Json.Decode.errorToString error ] | |
| } | |
| { start = { row = 1, column = 1 } | |
| , end = { row = List.length (String.lines content), column = 1 } | |
| } | |
| :: acc.errors | |
| } | |
| ) | |
| { errors = [], decodedFiles = Dict.empty } | |
| in | |
| ( errors, { context | translationFiles = decodedFiles } ) | |
| moduleVisitor : | |
| Rule.ModuleRuleSchema schemaState ModuleContext | |
| -> Rule.ModuleRuleSchema { schemaState | hasAtLeastOneVisitor : () } ModuleContext | |
| moduleVisitor schemaState = | |
| schemaState | |
| |> Rule.withExpressionEnterVisitor expressionVisitor | |
| expressionVisitor : Node Expression -> ModuleContext -> ( List (Rule.Error {}), ModuleContext ) | |
| expressionVisitor node context = | |
| case Node.value node of | |
| Expression.FunctionOrValue _ name -> | |
| if ModuleNameLookupTable.moduleNameFor context.moduleNameLookupTable node == Just Translations.i18nModuleName then | |
| ( [] | |
| , { context | |
| | referencedTranslations = | |
| FastSet.insert name context.referencedTranslations | |
| } | |
| ) | |
| else | |
| ( [], context ) | |
| _ -> | |
| ( [], context ) | |
| fromProjectToModule : Rule.ContextCreator ProjectContext ModuleContext | |
| fromProjectToModule = | |
| Rule.initContextCreator | |
| (\moduleNameLookupTable _ -> | |
| { moduleNameLookupTable = moduleNameLookupTable | |
| , referencedTranslations = FastSet.empty | |
| } | |
| ) | |
| |> Rule.withModuleNameLookupTable | |
| fromModuleToProject : Rule.ContextCreator ModuleContext ProjectContext | |
| fromModuleToProject = | |
| Rule.initContextCreator | |
| (\moduleContext -> | |
| { translationFiles = Dict.empty | |
| , referencedTranslations = moduleContext.referencedTranslations | |
| } | |
| ) | |
| foldProjectContexts : ProjectContext -> ProjectContext -> ProjectContext | |
| foldProjectContexts context1 context2 = | |
| { translationFiles = Dict.union context1.translationFiles context2.translationFiles | |
| , referencedTranslations = FastSet.union context1.referencedTranslations context2.referencedTranslations | |
| } | |
| finalProjectEvaluation : ProjectContext -> List (Rule.Error { useErrorForModule : () }) | |
| finalProjectEvaluation context = | |
| -- Look through translation files and find unused translations | |
| -- return an error with a fix to remove the unused translation | |
| context.translationFiles | |
| |> Dict.toList | |
| |> List.concatMap | |
| (\( path, { fileKey, content, translations } ) -> | |
| translations | |
| |> Dict.toList | |
| |> List.filter | |
| (\( translationKey, _ ) -> | |
| let | |
| {- Matching travelm-agency's format: | |
| https://github.com/anmolitor/travelm-agency/blob/3db8d177003625c0cde3dbd7b2067dd0f1f7d263/src/Util.elm#L15-L17 | |
| -} | |
| normalizedTranslationKey = | |
| translationKey | |
| |> String.Extra.classify | |
| |> String.Extra.decapitalize | |
| in | |
| not (FastSet.member normalizedTranslationKey context.referencedTranslations) | |
| ) | |
| |> List.map | |
| (\( translationKey, translationValue ) -> | |
| let | |
| line = | |
| String.lines content | |
| |> List.Extra.findIndex (String.trimLeft >> String.startsWith (String.Extra.surround "\"" translationKey)) | |
| |> Maybe.withDefault 0 | |
| fixed = | |
| context.translationFiles | |
| |> Dict.get path | |
| |> Maybe.map | |
| (\file -> | |
| file.translations | |
| |> Dict.remove translationKey | |
| |> Translations.encode | |
| ) | |
| |> Maybe.withDefault content | |
| in | |
| Rule.errorForExtraFileWithFix fileKey | |
| { message = "Unused translation: \"" ++ translationKey ++ "\"" | |
| , details = | |
| [ "This translation is not being used in the project and can be removed." | |
| , "Translation file: " ++ path | |
| ] | |
| } | |
| { start = { row = line + 1, column = 3 } | |
| , end = | |
| let | |
| enoughExtraColumns = | |
| 9 | |
| in | |
| { row = line + 1 | |
| , column = String.length (translationValue ++ translationKey) + enoughExtraColumns | |
| } | |
| } | |
| [ Fix.replaceRangeBy | |
| { start = { row = 1, column = 1 } | |
| , end = { row = List.length (String.lines content) + 1, column = 1 } | |
| } | |
| fixed | |
| ] | |
| ) | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment