Skip to content

[Command] Audit #763

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,21 @@ print(LocaleKeys.title.tr()); //String
Text(LocaleKeys.title).tr(); //Widget
```

### ✅ Audit missing keys

If you prefer to not generate keys you can see an audit of your translation keys to see the one present in your app code but not in your translations file by running the audit command.

```
flutter pub run easy_localization:audit
```

If you are not using the default translations folder path (assets/translations) or the lib folder for your code you can specify your custom paths :

| Arguments | Short | Default | Description |
| ---------------------------- | ----- | --------------------- | --------------------------------------------------------------------------- |
| --translations-dir | -t | assets/translations | Folder containing localization files |
| --source-dir | -s | lib | Folder containing the app code files |

## 🖨️ Logger

[Easy Localization] logger based on [Easy Logger]
Expand Down
33 changes: 33 additions & 0 deletions bin/audit.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'dart:io';

import 'package:args/args.dart';
import 'audit/audit_command.dart';

void main(List<String> args) {
final actual = args.isEmpty ? ['audit'] : args;
var parser = ArgParser();

parser.addOption('translations-dir', abbr: 't', defaultsTo: 'assets/translations');
parser.addOption('source-dir', abbr: 's', defaultsTo: 'lib');

try {
var argResults = parser.parse(actual);
final transDir = argResults['translations-dir'] as String;
final srcDir = argResults['source-dir'] as String;

if (!Directory(transDir).existsSync()) {
stderr.writeln('Error: Translation directory "$transDir" does not exist.');
exit(1);
}

if (!Directory(srcDir).existsSync()) {
stderr.writeln('Error: Source directory "$srcDir" does not exist.');
exit(1);
}

AuditCommand().run(transDir: transDir, srcDir: srcDir);
} catch (e) {
stderr.writeln('Error: $e');
exit(1);
}
}
152 changes: 152 additions & 0 deletions bin/audit/audit_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import 'dart:convert';
import 'dart:io';

import 'package:path/path.dart';

class AuditCommand {
void run({required String transDir, required String srcDir}) {
try {
final translationDir = Directory(transDir);
final sourceDir = Directory(srcDir);

if (!translationDir.existsSync()) {
stderr.writeln('Error: Translation directory "$transDir" does not exist.');
return;
}

if (!sourceDir.existsSync()) {
stderr.writeln('Error: Source directory "$srcDir" does not exist.');
return;
}

final allTranslations = _loadTranslations(translationDir);
final usedKeys = _scanSourceForKeys(sourceDir);

_report(allTranslations, usedKeys);
} catch (e) {
stderr.writeln('Error during audit: $e');
}
}

/// Walks [translationsDir], reads every `.json`, flattens nested maps
/// into dot‑separated keys, and returns a map:
/// { 'en': {'home.title', 'home.subtitle', …}, 'fr': { … } }
Map<String, Set<String>> _loadTranslations(Directory translationsDir) {
final result = <String, Set<String>>{};
for (var file in translationsDir.listSync().whereType<File>()) {
if (!file.path.endsWith('.json')) continue;

try {
final langCode = basenameWithoutExtension(file.path);
final jsonMap = json.decode(file.readAsStringSync()) as Map<String, dynamic>;
result[langCode] = _flatten(jsonMap);
} catch (e) {
stderr.writeln('Error reading ${file.path}: $e');
}
}
return result;
}

Set<String> _flatten(Map<String, dynamic> json, [String parentKey = '']) {
final keys = <String>{};
for (var entry in json.entries) {
final key = entry.key;
final value = entry.value;

final newKey = parentKey.isEmpty ? key : '$parentKey.$key';
if (value is String) {
keys.add(newKey);
continue;
}

if (value is Map<String, dynamic>) {
keys.addAll(_flatten(value, newKey));
continue;
}

if (value is List || value is num || value is bool) {
keys.add(newKey);
}
}
return keys;
}

Set<String> _scanSourceForKeys(Directory srcDir) {
List<RegExp> keyPatterns = [
// 1) tr('foo.bar') or tr("foo.bar"), with optional args/comma before the )
RegExp(r"""\btr\s*\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\)"""),

// 2) context.tr('foo.bar') same as above but with the context qualifier
RegExp(r"""context\s*\.\s*tr\s*\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\)"""),

// 3) 'foo.bar'.tr() or "foo.bar".tr(), allowing whitespace/newlines
RegExp(r"""['"]([^'"]+)['"]\s*\.\s*tr\s*\(\s*[^)]*\)"""),

// 4) generated keys: LocaleKeys.foo_bar (whitespace around the dot ok)
RegExp(r"""LocaleKeys\s*\.\s*([A-Za-z0-9_]+)"""),

// 5) plural() calls
RegExp(r"""\bplural\s*\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\)"""),

// 6) context.plural() calls
RegExp(r"""context\s*\.\s*plural\s*\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\)"""),
];

final used = <String>{};

for (var file in srcDir.listSync(recursive: true).whereType<File>().where((f) => f.path.endsWith('.dart'))) {
try {
final content = file.readAsStringSync();
for (var pattern in keyPatterns) {
final matches = pattern.allMatches(content);
for (var match in matches) {
if (match.groupCount > 0) {
String key = match.group(1)!;
if (pattern.pattern.contains('LocaleKeys')) {
key = key.replaceAll('_', '.');
}
used.add(key);
}
}
}
} catch (e) {
stderr.writeln('Error reading ${file.path}: $e');
}
}

return used;
}

void _report(Map<String, Set<String>> allTranslations, Set<String> usedKeys) {
stderr.writeln('=== Keys Audit ===');

for (var lang in allTranslations.keys) {
final keysInFile = allTranslations[lang]!;
final missing = usedKeys.difference(keysInFile);
final missingWithVariables = missing.where((key) => key.contains('\$')).toList();
final missingWithoutVariables = missing.where((key) => !key.contains('\$')).toList();

stderr.writeln('\nLanguage: $lang');
if (missingWithVariables.isEmpty && missingWithoutVariables.isEmpty) {
stderr.writeln(' ✅ all good!');
}

if (missingWithoutVariables.isNotEmpty) {
stderr.writeln(' 🔴 Missing (${missingWithoutVariables.length}):');
for (var key in missingWithoutVariables) {
stderr.writeln(' – $key');
}

stderr.writeln('\n');
}

if (missingWithVariables.isNotEmpty) {
stderr.writeln(' 🟡 Missing with variables (${missingWithVariables.length}):');
stderr.writeln(' These keys may not be missing as they contain variables that cannot be verified.');
for (var key in missingWithVariables) {
stderr.writeln(' – $key');
}
}
}
}
}