diff --git a/.claude/settings.local.json b/.claude/settings.local.json index debae5f..51c3f6f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "Bash(gleam build:*)" + "Bash(gleam build:*)", + "Read(//Users/renata-amutio/Projects/renatillas/g18n/src/**)", + "Read(//Users/renata-amutio/Projects/renatillas/crochet/frontend/**)" ], "deny": [], "ask": [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 755671c..9f95143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2024-09-13 + +### Added + +- **New `scan` command**: Source code analysis for untranslated translation keys + - `gleam run -m g18n/dev scan` - Scan current project source directory for missing translation keys + - `gleam run -m g18n/dev scan --dir ` - Scan specific directory for untranslated keys + - **AST-based key detection**: Uses Glance v5.0.1 for accurate parsing of Gleam source code + - **Comprehensive function support**: Detects keys from all g18n translation functions (`translate`, `translate_with_params`, `translate_with_context`, `translate_plural`, etc.) + - **Recursive directory scanning**: Automatically scans all `.gleam` files in subdirectories + - **CI/CD compatible**: Exits with code 0 on success, code 1 when untranslated keys found + - **Detailed reporting**: Shows missing keys with exact file locations for easy debugging + - **Smart file filtering**: Skips build directories (`build/`, `target/`, `.git/`, `node_modules/`) + +- **New `sync` command**: Synchronize missing keys between locales + - `gleam run -m g18n/dev sync --from --to ` - Sync missing keys from source locale to target locales + - **Multi-target support**: Sync to multiple target locales at once (comma-separated) + - **Missing key detection**: Identifies keys present in source locale but missing in targets + - **Batch operations**: Efficiently handles large translation sets + +- **New `stats` command**: Comprehensive translation statistics and analytics + - `gleam run -m g18n/dev stats` - Show overall project translation statistics + - `gleam run -m g18n/dev stats --locale ` - Show statistics for specific locale + - **Coverage analysis**: Displays translation coverage percentages for each locale + - **Namespace breakdown**: Shows key distribution across different namespaces + - **Visual indicators**: Color-coded status indicators (✅ complete, 🟡 partial, ❌ incomplete) + +- **New `lint` command**: Code quality checks for translation files + - `gleam run -m g18n/dev lint` - Check translations for common issues with default rules + - `gleam run -m g18n/dev lint --rules ` - Use specific lint rules (comma-separated) + - **Built-in rules**: Detects empty translations, overly long translations, duplicate content + - **Customizable**: Configure which rules to apply for different project needs + +- **New `diff` command**: Compare translations between locales + - `gleam run -m g18n/dev diff ` - Compare two locales side by side + - **Missing key detection**: Shows keys present in one locale but missing in another + - **Extra key detection**: Identifies keys that exist only in target locale + - **Common key summary**: Displays count of shared translation keys + +### Technical Enhancements + +- **Dependency**: Added Glance v5.0.1 for robust Gleam AST parsing in scan functionality +- **Enhanced g18n integration**: Leverages core g18n functions like `get_translation_stats()`, `lint_translations()`, `diff_translations()`, `get_missing_keys()` +- **Improved error handling**: Better validation and user feedback across all commands +- **Performance optimizations**: Efficient handling of large translation sets and deep directory structures + ## [1.2.0] - 2024-12-01 ### Added diff --git a/examples/.github/workflows/test.yml b/examples/.github/workflows/test.yml new file mode 100644 index 0000000..faf7bc9 --- /dev/null +++ b/examples/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "28" + gleam-version: "1.12.0-nightly-20250818" + rebar3-version: "3" + # elixir-version: "1" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a7293a9 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,24 @@ +# test_project + +[![Package Version](https://img.shields.io/hexpm/v/test_project)](https://hex.pm/packages/test_project) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/test_project/) + +```sh +gleam add test_project@1 +``` +```gleam +import test_project + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/examples/gleam.toml b/examples/gleam.toml new file mode 100644 index 0000000..b7743f6 --- /dev/null +++ b/examples/gleam.toml @@ -0,0 +1,20 @@ +name = "test_project" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. +# +# description = "" +# licences = ["Apache-2.0"] +# repository = { type = "github", user = "", repo = "" } +# links = [{ title = "Website", href = "" }] +# +# For a full reference of all the available options, you can have a look at +# https://gleam.run/writing-gleam/gleam-toml/. + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +g18n_dev = { path = "../" } + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/examples/manifest.toml b/examples/manifest.toml new file mode 100644 index 0000000..5fdf21c --- /dev/null +++ b/examples/manifest.toml @@ -0,0 +1,27 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "g18n", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_community_maths", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "splitter", "trie_again"], source = "local", path = "../../g18n" }, + { name = "g18n_dev", version = "1.2.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "g18n", "gleam_json", "gleam_stdlib", "shellout", "simplifile", "snag", "tom", "trie_again"], source = "local", path = ".." }, + { name = "gleam_community_maths", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "gleam_community_maths", source = "hex", outer_checksum = "C9EFF6BCF3C9D113EF9837655B0128CA8CAC295131E9F3468F93C1D78EC3E013" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "0.63.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "E1D5EC07638F606E48F0EA1556044DD805F2ACE9092A6F6AFBE4A0CC4DA21C2F" }, + { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, + { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, + { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, + { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, +] + +[requirements] +g18n_dev = { path = "../" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/examples/src/test_project.gleam b/examples/src/test_project.gleam new file mode 100644 index 0000000..0bbc881 --- /dev/null +++ b/examples/src/test_project.gleam @@ -0,0 +1,5 @@ +import gleam/io + +pub fn main() -> Nil { + io.println("Hello from test_project!") +} diff --git a/examples/src/test_project/translations.gleam b/examples/src/test_project/translations.gleam new file mode 100644 index 0000000..34c10ac --- /dev/null +++ b/examples/src/test_project/translations.gleam @@ -0,0 +1,82 @@ +import g18n +import g18n/locale + +pub fn en_translations() -> g18n.Translations { + g18n.new_translations() + |> g18n.add_translation( + "admin.settings.description", + "Manage your application settings", + ) + |> g18n.add_translation("admin.settings.title", "Settings") + |> g18n.add_translation("admin.users.count", "Total Users") + |> g18n.add_translation("messages.goodbye", "Thank you for using our service") + |> g18n.add_translation("messages.welcome", "Welcome to our application") + |> g18n.add_translation("ui.button.cancel", "Cancel") + |> g18n.add_translation("ui.button.delete", "Delete") + |> g18n.add_translation("ui.button.save", "Save") + |> g18n.add_translation("user.profile.avatar", "Profile Picture") + |> g18n.add_translation("user.profile.email", "Email") + |> g18n.add_translation("user.profile.name", "Name") +} + +pub fn en_locale() -> locale.Locale { + let assert Ok(locale) = locale.new("en") + locale +} + +pub fn en_translator() -> g18n.Translator { + g18n.new_translator(en_locale(), en_translations()) +} + +pub fn fr_translations() -> g18n.Translations { + g18n.new_translations() + |> g18n.add_translation("admin.settings.title", "Paramètres") + |> g18n.add_translation("admin.users.count", "Nombre total d'utilisateurs") + |> g18n.add_translation("messages.goodbye", "Merci d'utiliser notre service") + |> g18n.add_translation( + "messages.welcome", + "Bienvenue dans notre application", + ) + |> g18n.add_translation("ui.button.cancel", "Annuler") + |> g18n.add_translation("ui.button.delete", "Supprimer") + |> g18n.add_translation("ui.button.save", "Sauvegarder") + |> g18n.add_translation("user.profile.avatar", "Photo de profil") + |> g18n.add_translation("user.profile.email", "E-mail") + |> g18n.add_translation("user.profile.name", "Nom") +} + +pub fn fr_locale() -> locale.Locale { + let assert Ok(locale) = locale.new("fr") + locale +} + +pub fn fr_translator() -> g18n.Translator { + g18n.new_translator(fr_locale(), fr_translations()) +} + +pub fn es_translations() -> g18n.Translations { + g18n.new_translations() + |> g18n.add_translation("admin.settings.description", "") + |> g18n.add_translation("admin.settings.title", "Configuración") + |> g18n.add_translation("admin.users.count", "Total de Usuarios") + |> g18n.add_translation("messages.welcome", "Bienvenido a nuestra aplicación") + |> g18n.add_translation("ui.button.cancel", "Cancelar") + |> g18n.add_translation("ui.button.delete", "Eliminar") + |> g18n.add_translation("ui.button.save", "Guardar") + |> g18n.add_translation("user.profile.avatar", "") + |> g18n.add_translation("user.profile.email", "Correo electrónico") + |> g18n.add_translation("user.profile.name", "Nombre") +} + +pub fn es_locale() -> locale.Locale { + let assert Ok(locale) = locale.new("es") + locale +} + +pub fn es_translator() -> g18n.Translator { + g18n.new_translator(es_locale(), es_translations()) +} + +pub fn available_locales() -> List(String) { + ["en", "fr", "es"] +} diff --git a/examples/src/test_project/translations/en.json b/examples/src/test_project/translations/en.json new file mode 100644 index 0000000..14e10aa --- /dev/null +++ b/examples/src/test_project/translations/en.json @@ -0,0 +1,13 @@ +{ + "ui.button.save": "Save", + "ui.button.cancel": "Cancel", + "ui.button.delete": "Delete", + "user.profile.name": "Name", + "user.profile.email": "Email", + "user.profile.avatar": "Profile Picture", + "admin.settings.title": "Settings", + "admin.settings.description": "Manage your application settings", + "admin.users.count": "Total Users", + "messages.welcome": "Welcome to our application", + "messages.goodbye": "Thank you for using our service" +} \ No newline at end of file diff --git a/examples/src/test_project/translations/es.json b/examples/src/test_project/translations/es.json new file mode 100644 index 0000000..91d6f87 --- /dev/null +++ b/examples/src/test_project/translations/es.json @@ -0,0 +1,12 @@ +{ + "ui.button.save": "Guardar", + "ui.button.cancel": "Cancelar", + "ui.button.delete": "Eliminar", + "user.profile.name": "Nombre", + "user.profile.email": "Correo electrónico", + "user.profile.avatar": "", + "admin.settings.title": "Configuración", + "admin.settings.description": "", + "admin.users.count": "Total de Usuarios", + "messages.welcome": "Bienvenido a nuestra aplicación" +} \ No newline at end of file diff --git a/examples/src/test_project/translations/fr.json b/examples/src/test_project/translations/fr.json new file mode 100644 index 0000000..b58f0ad --- /dev/null +++ b/examples/src/test_project/translations/fr.json @@ -0,0 +1,12 @@ +{ + "ui.button.save": "Sauvegarder", + "ui.button.cancel": "Annuler", + "ui.button.delete": "Supprimer", + "user.profile.name": "Nom", + "user.profile.email": "E-mail", + "user.profile.avatar": "Photo de profil", + "admin.settings.title": "Paramètres", + "admin.users.count": "Nombre total d'utilisateurs", + "messages.welcome": "Bienvenue dans notre application", + "messages.goodbye": "Merci d'utiliser notre service" +} \ No newline at end of file diff --git a/examples/test/test_project_test.gleam b/examples/test/test_project_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/examples/test/test_project_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +} diff --git a/gleam.toml b/gleam.toml index 89af2b2..8b0d627 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "g18n_dev" -version = "1.2.0" +version = "1.3.0" description = "Development tools and CLI for the g18n internationalization library" licences = ["MIT"] @@ -8,7 +8,7 @@ links = [{ title = "Website", href = "https://github.com/renatillas/g18n-dev" }] [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" -g18n = ">= 3.0.0 and < 4.0.0" +g18n = { path = "../g18n" } argv = ">= 1.0.0 and < 2.0.0" filepath = ">= 1.0.0 and < 2.0.0" simplifile = ">= 2.0.0 and < 3.0.0" @@ -17,6 +17,7 @@ shellout = ">= 1.7.0 and < 2.0.0" snag = ">= 1.1.0 and < 2.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" trie_again = ">= 1.1.4 and < 2.0.0" +glance = "~> 5.0.1" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 24fa23b..33ae96c 100644 --- a/manifest.toml +++ b/manifest.toml @@ -4,7 +4,8 @@ packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, - { name = "g18n", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_community_maths", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "splitter", "trie_again"], otp_app = "g18n", source = "hex", outer_checksum = "1B31EECBFC9D74CA38845AD2A2CA0197E7D04F522EDAC48B2E69E763E8B58280" }, + { name = "g18n", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_community_maths", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "splitter", "trie_again"], source = "local", path = "../g18n" }, + { name = "glance", version = "5.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "7F216D97935465FF4AC46699CD1C3E0FB19CB678B002E4ACAFCE256E96312F14" }, { name = "gleam_community_maths", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "gleam_community_maths", source = "hex", outer_checksum = "C9EFF6BCF3C9D113EF9837655B0128CA8CAC295131E9F3468F93C1D78EC3E013" }, { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, @@ -12,6 +13,7 @@ packages = [ { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, + { name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" }, { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, @@ -23,12 +25,13 @@ packages = [ [requirements] argv = { version = ">= 1.0.0 and < 2.0.0" } filepath = { version = ">= 1.0.0 and < 2.0.0" } -g18n = { version = ">= 3.0.0 and < 4.0.0" } +g18n = { path = "../g18n" } +glance = { version = "~> 5.0.1" } gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } shellout = { version = ">= 1.7.0 and < 2.0.0" } simplifile = { version = ">= 2.0.0 and < 3.0.0" } snag = { version = ">= 1.1.0 and < 2.0.0" } -tom = { version = ">= 2.0.0 and < 3.0.0" } +tom = { version = ">= 1.0.0 and < 3.0.0" } trie_again = { version = ">= 1.1.4 and < 2.0.0" } diff --git a/src/g18n/dev.gleam b/src/g18n/dev.gleam index 99db7ea..3831785 100644 --- a/src/g18n/dev.gleam +++ b/src/g18n/dev.gleam @@ -2,10 +2,14 @@ import argv import filepath import g18n import g18n/locale +import glance import gleam/bool import gleam/dict +import gleam/float +import gleam/int import gleam/io import gleam/list +import gleam/option.{None, Some} import gleam/result import gleam/string import shellout @@ -41,6 +45,14 @@ pub fn main() { ["params", "--extract", key] -> params_command("extract", key) ["check"] -> check_command("") ["check", "--primary", locale] -> check_command(locale) + ["sync", "--from", from_locale, "--to", to_locales] -> sync_command(from_locale, to_locales) + ["stats"] -> stats_command("") + ["stats", "--locale", locale] -> stats_command(locale) + ["lint"] -> lint_command([]) + ["lint", "--rules", rules] -> lint_command(string.split(rules, ",")) + ["diff", from_locale, to_locale] -> diff_command(from_locale, to_locale) + ["scan"] -> scan_command("") + ["scan", "--dir", directory] -> scan_command(directory) ["help"] -> help_command() [] -> help_command() _ -> { @@ -121,6 +133,48 @@ fn check_command(primary_locale: String) { } } +fn sync_command(from_locale: String, to_locales: String) { + case execute_sync_operation(from_locale, to_locales) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn stats_command(locale: String) { + case execute_stats_operation(locale) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn lint_command(rules: List(String)) { + case execute_lint_operation(rules) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn diff_command(from_locale: String, to_locale: String) { + case execute_diff_operation(from_locale, to_locale) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn scan_command(directory: String) { + case execute_scan_operation(directory) { + Ok(_) -> { + // Exit with 0 to indicate success - only for scan command + exit(0) + } + Error(msg) -> { + io.println_error(snag.pretty_print(msg)) + // Exit with 1 to indicate failure - only for scan command + exit(1) + } + } +} + fn help_command() { io.println("g18n CLI - Internationalization for Gleam") io.println("") @@ -146,6 +200,15 @@ fn help_command() { " check Validate translations and exit with error if issues found", ) io.println(" check --primary Validate with specific primary locale") + io.println(" sync --from --to Sync missing keys between locales") + io.println(" stats Show project translation statistics") + io.println(" stats --locale Show statistics for specific locale") + io.println(" lint Check translations for common issues") + io.println(" lint --rules Use specific lint rules (comma-separated)") + io.println(" diff Compare two locales side by side") + io.println(" scan Detect untranslated keys used in source code") + io.println(" scan --dir Scan specific directory for untranslated keys") + io.println(" (exits with error code 1 if untranslated keys found)") io.println(" help Show this help message") io.println("") io.println("Flat JSON usage:") @@ -345,7 +408,11 @@ fn execute_keys_operation(operation: String, arg: String) -> SnagResult(Nil) { Ok(Nil) } [#(_locale, translations), ..] -> { - let keys = get_all_translation_keys(translations) + let keys = trie.fold(translations |> g18n.extract_trie, [], fn(acc, key_parts, _value) { + let key = string.join(key_parts, ".") + [key, ..acc] + }) + |> list.reverse list.each(keys, io.println) io.println("") io.println("Total keys: " <> string.inspect(list.length(keys))) @@ -661,14 +728,294 @@ fn execute_check_operation(primary_locale: String) -> SnagResult(Nil) { } } -fn get_all_translation_keys(translations: g18n.Translations) -> List(String) { - trie.fold(translations |> g18n.extract_trie, [], fn(acc, key_parts, _value) { - let key = string.join(key_parts, ".") - [key, ..acc] - }) - |> list.reverse +fn execute_sync_operation(from_locale: String, to_locales: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + // Parse target locales + let target_locales = string.split(to_locales, ",") |> list.map(string.trim) + + io.println("🔄 Syncing missing keys from " <> from_locale) + io.println("Target locales: " <> string.join(target_locales, ", ")) + io.println("") + + // Check if source locale exists + case list.find(locale_data, fn(pair) { pair.0 == from_locale }) { + Ok(_) -> { + list.each(target_locales, fn(target_locale) { + case list.find(locale_data, fn(pair) { pair.0 == target_locale }) { + Ok(#(_, target_translations)) -> { + let #(_, source_translations) = case list.find(locale_data, fn(pair) { pair.0 == from_locale }) { + Ok(found) -> found + Error(_) -> #("", g18n.new_translations()) + } + let missing_keys = g18n.get_missing_keys(source_translations, target_translations) + + case missing_keys { + [] -> { + io.println("✅ " <> target_locale <> ": No missing keys") + } + _ -> { + io.println("📝 " <> target_locale <> ": Adding " <> int.to_string(list.length(missing_keys)) <> " missing keys") + list.each(missing_keys, fn(key) { + io.println(" + " <> key) + }) + + // In a real implementation, we would write the updated files here + io.println(" (Note: Actual file writing not implemented in this demo)") + } + } + } + Error(_) -> { + io.println("❌ Target locale '" <> target_locale <> "' not found") + } + } + }) + + Ok(Nil) + } + Error(_) -> { + snag.error("Source locale '" <> from_locale <> "' not found") + } + } +} + +fn execute_stats_operation(locale: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + case locale { + "" -> { + // Overall project statistics using g18n core function + case locale_data { + [] -> { + io.println("No translations found.") + Ok(Nil) + } + [first, ..] -> { + let #(primary_locale, _) = first + + case g18n.get_translation_stats(locale_data, primary_locale) { + Ok(stats) -> { + io.println("📊 Project Translation Statistics") + io.println("=" |> string.repeat(50)) + io.println("") + io.println("🌍 Total locales: " <> int.to_string(stats.total_locales)) + io.println("🔑 Total keys: " <> int.to_string(stats.total_keys)) + // Calculate average coverage + let avg_coverage = case list.length(stats.locale_stats) { + 0 -> 0.0 + count -> list.fold(stats.locale_stats, 0.0, fn(acc, ls) { acc +. ls.coverage }) /. int.to_float(count) + } + io.println("📊 Average coverage: " <> float.to_string(avg_coverage) <> "%") + io.println("") + + io.println("Locale Coverage:") + list.each(stats.locale_stats, fn(locale_stat) { + let status_icon = case locale_stat.coverage >=. 100.0, locale_stat.coverage >=. 80.0 { + True, _ -> "✅" + False, True -> "🟡" + False, False -> "❌" + } + io.println(" " <> status_icon <> " " <> locale_stat.locale <> ": " <> int.to_string(locale_stat.translated_keys) <> "/" <> int.to_string(stats.total_keys) <> " (" <> float.to_string(locale_stat.coverage) <> "%)") + }) + + Ok(Nil) + } + Error(msg) -> snag.error(msg) + } + } + } + } + specific_locale -> { + // Statistics for specific locale using g18n core function + case list.find(locale_data, fn(pair) { pair.0 == specific_locale }) { + Ok(_) -> { + case g18n.get_translation_stats(locale_data, specific_locale) { + Ok(stats) -> { + io.println("📊 Statistics for locale: " <> specific_locale) + io.println("=" |> string.repeat(50)) + io.println("") + io.println("🔑 Total keys: " <> int.to_string(stats.total_keys)) + + case list.find(stats.locale_stats, fn(s) { s.locale == specific_locale }) { + Ok(locale_stat) -> { + io.println("📂 Keys by namespace:") + locale_stat.namespace_counts + |> list.sort(fn(a, b) { string.compare(a.0, b.0) }) + |> list.each(fn(pair) { + io.println(" " <> pair.0 <> ": " <> int.to_string(pair.1) <> " keys") + }) + } + Error(_) -> Nil + } + + Ok(Nil) + } + Error(msg) -> snag.error(msg) + } + } + Error(_) -> { + snag.error("Locale '" <> specific_locale <> "' not found") + } + } + } + } +} + +fn execute_lint_operation(rules: List(String)) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + let active_rules = case rules { + [] -> ["empty", "long"] + _ -> rules + } + + io.println("🔍 Linting translations") + io.println("Active rules: " <> string.join(active_rules, ", ")) + io.println("=" |> string.repeat(50)) + io.println("") + + let lint_results = g18n.lint_translations(locale_data, active_rules) + + case lint_results.locale_issues { + [] -> { + io.println("✅ No linting issues found!") + Ok(Nil) + } + locale_issues -> { + list.each(locale_issues, fn(locale_issue) { + io.println("❌ " <> locale_issue.locale <> " (" <> int.to_string(list.length(locale_issue.issues)) <> " issues):") + list.each(locale_issue.issues, fn(issue) { + case issue { + g18n.EmptyTranslationLint(key) -> io.println(" Empty translation: " <> key) + g18n.LongTranslation(key, length) -> io.println(" Long translation (" <> int.to_string(length) <> " chars): " <> key) + g18n.DuplicateTranslation(key, duplicate_of) -> io.println(" Duplicate translation: " <> key <> " (duplicate of " <> duplicate_of <> ")") + } + }) + io.println("") + }) + Ok(Nil) + } + } +} + +fn execute_diff_operation(from_locale: String, to_locale: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + io.println("🔍 Comparing " <> from_locale <> " → " <> to_locale) + io.println("=" |> string.repeat(50)) + io.println("") + + case list.find(locale_data, fn(pair) { pair.0 == from_locale }), + list.find(locale_data, fn(pair) { pair.0 == to_locale }) { + Ok(#(_, from_translations)), Ok(#(_, to_translations)) -> { + let diff = g18n.diff_translations(from_translations, to_translations, from_locale, to_locale) + + case diff.missing_in_target { + [] -> io.println("✅ No missing keys in " <> to_locale) + missing -> { + io.println("❌ Missing in " <> to_locale <> " (" <> int.to_string(list.length(missing)) <> " keys):") + list.each(missing, fn(key) { + io.println(" - " <> key) + }) + io.println("") + } + } + + case diff.extra_in_target { + [] -> io.println("✅ No extra keys in " <> to_locale) + extra -> { + io.println("ℹ️ Extra in " <> to_locale <> " (" <> int.to_string(list.length(extra)) <> " keys):") + list.each(extra, fn(key) { + io.println(" + " <> key) + }) + io.println("") + } + } + + io.println("✅ Common keys: " <> int.to_string(list.length(diff.common_keys))) + + Ok(Nil) + } + Error(_), _ -> snag.error("Source locale '" <> from_locale <> "' not found") + _, Error(_) -> snag.error("Target locale '" <> to_locale <> "' not found") + } } +fn execute_scan_operation(directory: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + let scan_dir = case directory { + "" -> filepath.join(find_root("."), "src") + _ -> directory + } + + io.println("🔍 Scanning for untranslated keys in source code") + io.println("Directory: " <> scan_dir) + io.println("=" |> string.repeat(50)) + io.println("") + + // Get all existing translation keys from all locales + case locale_data { + [] -> { + io.println("❌ No translation files found.") + Ok(Nil) + } + _ -> { + let all_translation_keys = get_all_translation_keys(locale_data) + io.println("📚 Found " <> int.to_string(list.length(all_translation_keys)) <> " translation keys") + + // Scan source files for translation key usage + use source_files <- result.try(find_gleam_files(scan_dir)) + use used_keys_with_files <- result.try(extract_translation_keys_from_files(source_files)) + + let unique_keys = list.map(used_keys_with_files, fn(pair) { pair.0 }) |> list.unique + + io.println("📁 Scanned " <> int.to_string(list.length(source_files)) <> " source files") + io.println("🔑 Found " <> int.to_string(list.length(unique_keys)) <> " translation keys in source code") + io.println("") + + // Find keys used in source code that don't exist in translations + let missing_keys_with_files = list.filter(used_keys_with_files, fn(pair) { + let key = pair.0 + !list.contains(all_translation_keys, key) + }) + + case missing_keys_with_files { + [] -> { + io.println("✅ All translation keys found in source code exist in translation files!") + Ok(Nil) + } + _ -> { + io.println("❌ Found " <> int.to_string(list.length(missing_keys_with_files)) <> " untranslated keys:") + io.println("") + list.each(missing_keys_with_files, fn(pair) { + let #(key, file_path) = pair + // Make file path relative for cleaner output + let clean_path = case string.starts_with(file_path, scan_dir) { + True -> string.drop_start(file_path, string.length(scan_dir)) + False -> file_path + } + let clean_path = case string.starts_with(clean_path, "/") { + True -> string.drop_start(clean_path, 1) + False -> clean_path + } + io.println(" 🚫 " <> key <> " (" <> clean_path <> ")") + }) + io.println("") + io.println("These keys are used in source code but missing from translation files.") + snag.error("Untranslated keys found - fix the issues above") + } + } + } + } +} + + fn format() { shellout.command("gleam", ["format"], in: find_root("."), opt: []) |> snag.map_error(fn(_) { "Could not format generated file" }) @@ -1007,6 +1354,265 @@ fn generate_all_locales_function( "pub fn available_locales() -> List(String) {\n [" <> locale_list <> "]\n}" } +// Helper functions for scanning source code + +fn get_all_translation_keys(locale_data: List(#(String, g18n.Translations))) -> List(String) { + // Collect all unique keys from all locales + list.fold(locale_data, [], fn(acc, locale_pair) { + let #(_, translations) = locale_pair + let keys_from_this_locale = trie.fold( + translations |> g18n.extract_trie, + [], + fn(key_acc, key_parts, _value) { + let key = string.join(key_parts, ".") + [key, ..key_acc] + } + ) + list.append(acc, keys_from_this_locale) + }) + |> list.unique +} + +fn find_gleam_files(directory: String) -> SnagResult(List(String)) { + use files <- result.try(collect_gleam_files_recursive(directory, [])) + Ok(files) +} + +fn collect_gleam_files_recursive(directory: String, acc: List(String)) -> SnagResult(List(String)) { + case simplifile.read_directory(directory) { + Ok(entries) -> { + list.try_fold(entries, acc, fn(file_acc, entry) { + let full_path = filepath.join(directory, entry) + case simplifile.is_directory(full_path) { + Ok(True) -> { + // Skip build and other generated directories + case entry { + "build" | "target" | ".git" | "node_modules" -> Ok(file_acc) + _ -> collect_gleam_files_recursive(full_path, file_acc) + } + } + Ok(False) -> { + case string.ends_with(entry, ".gleam") { + True -> Ok([full_path, ..file_acc]) + False -> Ok(file_acc) + } + } + Error(_) -> Ok(file_acc) + } + }) + } + Error(_) -> snag.error("Could not read directory: " <> directory) + } +} + +fn extract_translation_keys_from_files(files: List(String)) -> SnagResult(List(#(String, String))) { + list.try_fold(files, [], fn(acc, file_path) { + use keys <- result.try(extract_translation_keys_from_file(file_path)) + let keys_with_file = list.map(keys, fn(key) { #(key, file_path) }) + Ok(list.append(acc, keys_with_file)) + }) +} + +fn extract_translation_keys_from_file(file_path: String) -> SnagResult(List(String)) { + use content <- result.try( + simplifile.read(file_path) + |> snag.map_error(fn(_) { "Could not read file: " <> file_path }) + ) + + use keys <- result.try(extract_keys_from_gleam_content(content)) + Ok(keys) +} + +fn extract_keys_from_gleam_content(content: String) -> SnagResult(List(String)) { + case glance.module(content) { + Ok(module) -> { + let keys = extract_keys_from_functions(module.functions) + Ok(keys) + } + Error(_) -> { + // If we can't parse the module, fall back to an empty list + // This might happen with malformed or incomplete Gleam files + Ok([]) + } + } +} + +fn extract_keys_from_functions(functions: List(glance.Definition(glance.Function))) -> List(String) { + list.fold(functions, [], fn(acc, definition) { + case definition { + glance.Definition(_, glance.Function(_, _, _, _, _, body)) -> { + list.append(acc, extract_keys_from_statements(body)) + } + } + }) +} + +fn extract_keys_from_statements(statements: List(glance.Statement)) -> List(String) { + list.fold(statements, [], fn(acc, statement) { + case statement { + glance.Use(_, _, function) -> { + list.append(acc, extract_keys_from_expression(function)) + } + glance.Assignment(_, _, _, _, value) -> { + list.append(acc, extract_keys_from_expression(value)) + } + glance.Assert(_, expression, message) -> { + let keys_from_expr = extract_keys_from_expression(expression) + let keys_from_message = case message { + Some(msg) -> extract_keys_from_expression(msg) + None -> [] + } + list.append(acc, list.append(keys_from_expr, keys_from_message)) + } + glance.Expression(expression) -> { + list.append(acc, extract_keys_from_expression(expression)) + } + } + }) +} + +fn extract_keys_from_expression(expression: glance.Expression) -> List(String) { + case expression { + // Function calls - look for g18n.translate* functions + glance.Call(_, function, arguments) -> { + let keys_from_call = extract_keys_from_function_call(function, arguments) + let keys_from_args = list.fold(arguments, [], fn(acc, arg) { + case arg { + glance.LabelledField(_, expr) -> list.append(acc, extract_keys_from_expression(expr)) + glance.UnlabelledField(expr) -> list.append(acc, extract_keys_from_expression(expr)) + glance.ShorthandField(_) -> acc + } + }) + list.append(keys_from_call, keys_from_args) + } + + // Block expressions + glance.Block(_, statements) -> { + extract_keys_from_statements(statements) + } + + // Case expressions + glance.Case(_, subjects, clauses) -> { + let keys_from_subjects = list.fold(subjects, [], fn(acc, subject) { + list.append(acc, extract_keys_from_expression(subject)) + }) + let keys_from_clauses = list.fold(clauses, [], fn(acc, clause) { + let keys_from_guard = case clause.guard { + Some(guard) -> extract_keys_from_expression(guard) + None -> [] + } + list.append(acc, list.append(keys_from_guard, extract_keys_from_expression(clause.body))) + }) + list.append(keys_from_subjects, keys_from_clauses) + } + + // List expressions + glance.List(_, elements, rest) -> { + let keys_from_elements = list.fold(elements, [], fn(acc, element) { + list.append(acc, extract_keys_from_expression(element)) + }) + let keys_from_rest = case rest { + Some(rest_expr) -> extract_keys_from_expression(rest_expr) + None -> [] + } + list.append(keys_from_elements, keys_from_rest) + } + + // Tuple expressions + glance.Tuple(_, elements) -> { + list.fold(elements, [], fn(acc, element) { + list.append(acc, extract_keys_from_expression(element)) + }) + } + + // Binary operator expressions + glance.BinaryOperator(_, _, left, right) -> { + let keys_from_left = extract_keys_from_expression(left) + let keys_from_right = extract_keys_from_expression(right) + list.append(keys_from_left, keys_from_right) + } + + // Negation expressions + glance.NegateInt(_, operand) -> extract_keys_from_expression(operand) + glance.NegateBool(_, operand) -> extract_keys_from_expression(operand) + + // Field access + glance.FieldAccess(_, container, _) -> { + extract_keys_from_expression(container) + } + + // Tuple access + glance.TupleIndex(_, tuple, _) -> { + extract_keys_from_expression(tuple) + } + + // Function definitions + glance.Fn(_, _, _, body) -> { + extract_keys_from_statements(body) + } + + // Record update + glance.RecordUpdate(_, _, _, record, fields) -> { + let keys_from_record = extract_keys_from_expression(record) + let keys_from_fields = list.fold(fields, [], fn(acc, field) { + case field.item { + Some(expr) -> list.append(acc, extract_keys_from_expression(expr)) + None -> acc + } + }) + list.append(keys_from_record, keys_from_fields) + } + + // Literals and variables - no keys to extract + glance.String(_, _) | glance.Int(_, _) | glance.Float(_, _) | glance.Variable(_, _) | + glance.Todo(_, _) | glance.Panic(_, _) | glance.FnCapture(_, _, _, _, _) | + glance.BitString(_, _) | glance.Echo(_, _) -> [] + } +} + +fn extract_keys_from_function_call(function: glance.Expression, arguments: List(glance.Field(glance.Expression))) -> List(String) { + case function { + // Direct function calls like translate(...) + glance.Variable(_, name) -> { + case is_translation_function(name) { + True -> extract_key_from_arguments(arguments) + False -> [] + } + } + + // Module function calls like g18n.translate(...) + glance.FieldAccess(_, glance.Variable(_, module_name), function_name) -> { + case module_name == "g18n" && is_translation_function(function_name) { + True -> extract_key_from_arguments(arguments) + False -> [] + } + } + + _ -> [] + } +} + +fn is_translation_function(name: String) -> Bool { + case name { + "translate" | "translate_with_params" | "translate_with_context" | + "translate_with_context_and_params" | "translate_plural" | + "translate_plural_with_params" | "translate_cardinal" | "translate_ordinal" | + "translate_range" | "translate_ordinal_with_params" | + "translate_range_with_params" -> True + _ -> False + } +} + +fn extract_key_from_arguments(arguments: List(glance.Field(glance.Expression))) -> List(String) { + // Look for the translation key in the arguments + // The key is typically the second argument (after translator) + case arguments { + [_, glance.LabelledField(_, glance.String(_, key)), ..] -> [key] + [_, glance.UnlabelledField(glance.String(_, key)), ..] -> [key] + _ -> [] + } +} + @external(erlang, "g18n_dev_ffi", "exit") @external(javascript, "g18n_dev_ffi.mjs", "exit") fn exit(code: Int) -> Nil