Skip to content

Instantly share code, notes, and snippets.

@pete-murphy
Created March 2, 2026 13:24
Show Gist options
  • Select an option

  • Save pete-murphy/4a23c4aa8f2d1b4678ea81614709791c to your computer and use it in GitHub Desktop.

Select an option

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).
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