diff --git a/README.md b/README.md index 90f59686..6fde3dea 100644 --- a/README.md +++ b/README.md @@ -572,7 +572,7 @@ print(LocaleKeys.title.tr()); //String Text(LocaleKeys.title).tr(); //Widget ``` -### ✅ Audit missing keys +## ✅ 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. @@ -580,12 +580,21 @@ If you prefer to not generate keys you can see an audit of your translation keys flutter pub run easy_localization:audit ``` +### Arguments : + 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 | +| --show-warnings | -w | false | show the warning (keys that contains variables and thus cannot be verified) | + +### Errors : + +| Code | Description | +| ---- | ------------------------------------------------------------------------------------ | +| 1 | Some keys are used in the code that are not present in one of the localization keys | ## 🖨️ Logger diff --git a/bin/audit.dart b/bin/audit.dart index 5b64cf09..9b9fd962 100644 --- a/bin/audit.dart +++ b/bin/audit.dart @@ -9,11 +9,13 @@ void main(List args) { parser.addOption('translations-dir', abbr: 't', defaultsTo: 'assets/translations'); parser.addOption('source-dir', abbr: 's', defaultsTo: 'lib'); + parser.addFlag('show-warnings', abbr: 'w', defaultsTo: false); try { var argResults = parser.parse(actual); final transDir = argResults['translations-dir'] as String; final srcDir = argResults['source-dir'] as String; + final showWarnings = argResults['show-warnings'] as bool; if (!Directory(transDir).existsSync()) { stderr.writeln('Error: Translation directory "$transDir" does not exist.'); @@ -25,7 +27,7 @@ void main(List args) { exit(1); } - AuditCommand().run(transDir: transDir, srcDir: srcDir); + AuditCommand().run(transDir: transDir, srcDir: srcDir, showWarnings: showWarnings); } catch (e) { stderr.writeln('Error: $e'); exit(1); diff --git a/bin/audit/audit_command.dart b/bin/audit/audit_command.dart index a7833154..4a4a216c 100644 --- a/bin/audit/audit_command.dart +++ b/bin/audit/audit_command.dart @@ -1,11 +1,8 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:easy_localization/src/linked_file_resolver.dart'; -import 'package:path/path.dart'; -import 'package:easy_localization/src/file_loaders/io_file_loader.dart'; +import 'key_parser.dart'; class AuditCommand { - Future run({required String transDir, required String srcDir}) async { + Future run({required String transDir, required String srcDir, required bool showWarnings}) async { try { final translationDir = Directory(transDir); final sourceDir = Directory(srcDir); @@ -20,120 +17,17 @@ class AuditCommand { return; } - final allTranslations = await _loadTranslations(translationDir); - final usedKeys = _scanSourceForKeys(sourceDir); + final keyParser = KeyParser(); + final allTranslations = await keyParser.parseKeysInTranslationsDir(translationDir); + final usedKeys = keyParser.parseKeysInSourceDir(sourceDir); - _report(allTranslations, usedKeys); + _report(allTranslations, usedKeys, showWarnings: showWarnings); } 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': { … } } - /// Also handles linked translation files (those containing ':/file.json' references) - Future>> _loadTranslations(Directory translationsDir) async { - final result = >{}; - const IOFileLoader fileLoader = IOFileLoader(); - const LinkedFileResolver linkedFileResolver = JsonLinkedFileResolver(fileLoader: fileLoader); - - for (var file in translationsDir.listSync().whereType()) { - if (!file.path.endsWith('.json')) continue; - - try { - final local = basenameWithoutExtension(file.path); - final langCode = local.split('-').first; - final hasCountryCode = local.split('-').length > 1; - final countryCode = hasCountryCode ? local.split('-').last : null; - final jsonMap = json.decode(file.readAsStringSync()) as Map; - - // Process linked files if present using the shared resolver - final resolvedJson = await linkedFileResolver.resolveLinkedFiles( - basePath: translationsDir.path, - languageCode: langCode, - baseJson: jsonMap, - countryCode: countryCode, - ); - result[local] = _flatten(resolvedJson); - } catch (e) { - stderr.writeln('Error reading ${file.path}: $e'); - } - } - return result; - } - - Set _flatten(Map json, [String parentKey = '']) { - final keys = {}; - 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) { - keys.addAll(_flatten(value, newKey)); - continue; - } - - if (value is List || value is num || value is bool) { - keys.add(newKey); - } - } - return keys; - } - - Set _scanSourceForKeys(Directory srcDir) { - List 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 = {}; - - for (var file in srcDir.listSync(recursive: true).whereType().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> allTranslations, Set usedKeys) { + void _report(Map> allTranslations, Set usedKeys, {required bool showWarnings}) { stderr.writeln('=== Keys Audit ==='); for (var lang in allTranslations.keys) { @@ -143,7 +37,7 @@ class AuditCommand { final missingWithoutVariables = missing.where((key) => !key.contains('\$')).toList(); stderr.writeln('\nLanguage: $lang'); - if (missingWithVariables.isEmpty && missingWithoutVariables.isEmpty) { + if ((missingWithVariables.isEmpty || !showWarnings) && missingWithoutVariables.isEmpty) { stderr.writeln(' ✅ all good!'); } @@ -154,9 +48,10 @@ class AuditCommand { } stderr.writeln('\n'); + exit(1); } - if (missingWithVariables.isNotEmpty) { + if (missingWithVariables.isNotEmpty && showWarnings) { 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) { diff --git a/bin/audit/key_parser.dart b/bin/audit/key_parser.dart new file mode 100644 index 00000000..4515c1d4 --- /dev/null +++ b/bin/audit/key_parser.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:easy_localization/src/linked_file_resolver.dart'; +import 'package:path/path.dart'; +import 'package:easy_localization/src/file_loaders/io_file_loader.dart'; + +enum KeyPatternType { + trFunction(pattern: r"""\btr\s*\(\s*['"]([^'"]+)['"](?:(?!gender\s*:)[^)])*\)"""), + contextTrFunction(pattern: r"""context\s*\.\s*tr\s*\(\s*['"]([^'"]+)['"](?:(?!gender\s*:)[^)])*\)"""), + stringTrMethod(pattern: r""""([^'"]+)"\s*\.tr\s*\((?:(?!gender\s*:)[^)])*\)"""), + localeKeys(pattern: r"""LocaleKeys\s*\.\s*([A-Za-z0-9_]+)"""), + // plural + pluralFunction(pattern: r"""\bplural\s*\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\)""", keywords: ['other']), + contextPluralFunction( + pattern: r"""context\s*\.\s*plural\s*\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\)""", keywords: ['other']), + // gender + trFunctionWithGender( + pattern: r"""\btr\s*\(\s*['"]([^'"]+)['"]\s*,\s*gender\s*:\s*[^)]*\)""", keywords: ["male", "female"]), + contextTrFunctionWithGender( + pattern: r"""context\s*\.\s*tr\s*\(\s*['"]([^'"]+)['"]\s*,\s*gender\s*:\s*[^)]*\)""", + keywords: ["male", "female"]), + stringTrMethodWithGender(pattern: r""""([^'"]+)"\s*\.tr\s*\(\s*gender\s*:\s*[^)]*\)""", keywords: ["male", "female"]); + + const KeyPatternType({required this.pattern, this.keywords = const []}); + + final String pattern; + final List keywords; +} + +class KeyParser { + /// Walks [translationsDir], reads every `.json`, flattens nested maps + /// into dot‑separated keys, and returns a map: + /// { 'en': {'home.title', 'home.subtitle', …}, 'fr': { … } } + /// Also handles linked translation files (those containing ':/file.json' references) + Future>> parseKeysInTranslationsDir(Directory translationsDir) async { + final result = >{}; + const IOFileLoader fileLoader = IOFileLoader(); + const LinkedFileResolver linkedFileResolver = JsonLinkedFileResolver(fileLoader: fileLoader); + + for (var file in translationsDir.listSync().whereType()) { + if (!file.path.endsWith('.json')) continue; + + final local = basenameWithoutExtension(file.path); + final langCode = local.split('-').first; + final hasCountryCode = local.split('-').length > 1; + final countryCode = hasCountryCode ? local.split('-').last : null; + final jsonMap = json.decode(file.readAsStringSync()) as Map; + + // Process linked files if present using the shared resolver + final resolvedJson = await linkedFileResolver.resolveLinkedFiles( + basePath: translationsDir.path, + languageCode: langCode, + baseJson: jsonMap, + countryCode: countryCode, + ); + result[local] = _flatten(resolvedJson); + } + return result; + } + + Set _flatten(Map json, [String parentKey = '']) { + final keys = {}; + 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) { + keys.addAll(_flatten(value, newKey)); + continue; + } + + if (value is List || value is num || value is bool) { + keys.add(newKey); + } + } + return keys; + } + + Set parseKeysInSourceDir(Directory srcDir) { + final used = {}; + + List files = + srcDir.listSync(recursive: true).whereType().where((f) => f.path.endsWith('.dart')).toList(); + for (var file in files) { + final content = file.readAsStringSync(); + + // remove all comments to avoid false positives + // remove single-line comments that start the line (optionally preceded by whitespace) + // and multi-line /* ... */ blocks + final commentPattern = RegExp( + r'^[ \t]*//.*?$|/\*[\s\S]*?\*/', + multiLine: true, + ); + final commentRemovedContent = content.replaceAll(commentPattern, ''); + + for (var patternType in KeyPatternType.values) { + final pattern = RegExp(patternType.pattern); + final matches = pattern.allMatches(commentRemovedContent); + + for (var match in matches) { + if (match.groupCount > 0) { + String key = match.group(1)!; + if (pattern.pattern.contains('LocaleKeys')) { + key = key.replaceAll('_', '.'); + } + + if (patternType.keywords.isNotEmpty) { + for (var keyword in patternType.keywords) { + String keyWithKeyword = '$key.$keyword'; + used.add(keyWithKeyword); + } + + continue; + } + + used.add(key); + } + } + } + } + + return used; + } +} diff --git a/example/ios/Flutter/ephemeral/flutter_lldb_helper.py b/example/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 00000000..a88caf99 --- /dev/null +++ b/example/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/example/ios/Flutter/ephemeral/flutter_lldbinit b/example/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 00000000..e3ba6fbe --- /dev/null +++ b/example/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/pubspec.yaml b/pubspec.yaml index eaeaa376..9b2661cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ issue_tracker: https://github.com/aissat/easy_localization/issues version: 3.0.9 environment: - sdk: '>=2.12.0 <4.0.0' + sdk: '>=2.17.0 <4.0.0' dependencies: flutter: