From 7771e01d4460cb465cd0cdc4589956086c494126 Mon Sep 17 00:00:00 2001 From: Dilum De Silva Date: Wed, 22 Jul 2026 12:02:16 +1200 Subject: [PATCH] feat: configurable localization roots and accessors via pubspec.yaml --- CHANGELOG.md | 6 + CLAUDE.md | 6 +- README.md | 48 ++++++++ lib/src/config.dart | 86 +++++++++++++ lib/src/project_files.dart | 19 ++- lib/src/term_usage_ast.dart | 81 ++++++++++--- lib/src/unused_terms.dart | 20 +++- pubspec.lock | 2 +- pubspec.yaml | 3 +- test/config_test.dart | 94 +++++++++++++++ .../ignore_keys_project/app_en.arb | 5 + .../ignore_keys_project/lib/main.dart | 3 + .../ignore_keys_project/pubspec.yaml | 3 + .../source_paths_project/app_en.arb | 5 + .../source_paths_project/lib/main.dart | 3 + .../source_paths_project/pubspec.yaml | 3 + .../test/widget_usage.dart | 3 + .../test_default_project/app_en.arb | 5 + .../test_default_project/lib/main.dart | 3 + .../test/widget_usage.dart | 3 + test/term_usage_ast_test.dart | 113 ++++++++++++++++++ test/unused_terms_detection_test.dart | 27 +++++ 22 files changed, 514 insertions(+), 27 deletions(-) create mode 100644 lib/src/config.dart create mode 100644 test/config_test.dart create mode 100644 test/fixtures/ast_detection/ignore_keys_project/app_en.arb create mode 100644 test/fixtures/ast_detection/ignore_keys_project/lib/main.dart create mode 100644 test/fixtures/ast_detection/ignore_keys_project/pubspec.yaml create mode 100644 test/fixtures/ast_detection/source_paths_project/app_en.arb create mode 100644 test/fixtures/ast_detection/source_paths_project/lib/main.dart create mode 100644 test/fixtures/ast_detection/source_paths_project/pubspec.yaml create mode 100644 test/fixtures/ast_detection/source_paths_project/test/widget_usage.dart create mode 100644 test/fixtures/ast_detection/test_default_project/app_en.arb create mode 100644 test/fixtures/ast_detection/test_default_project/lib/main.dart create mode 100644 test/fixtures/ast_detection/test_default_project/test/widget_usage.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0340cda..538e3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.3.0 + +- Add optional `translations_cleaner:` section in `pubspec.yaml` to configure custom `localization_roots` and `localization_accessors`, merged with the built-in defaults so custom accessors (e.g. `context.R.someKey`) are no longer reported as unused. +- Add `source_paths` config (default `[lib]`) so directories like `test` can be scanned, keeping keys used only in tests. +- Add `ignore_keys` config to always keep keys the syntax scan cannot see (generated or dynamic usages). + ## 0.2.1 - Widen `analyzer` constraint to `>=8.0.0 <11.0.0` so the package resolves alongside `intl_utils ^2.8.14` and modern Flutter test tooling. diff --git a/CLAUDE.md b/CLAUDE.md index f378be8..f3b412d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,10 +43,14 @@ The package is a CLI tool built with `package:args` for command parsing and `pac - `delete_terms.dart` - Removes unused keys (and their `@key` attributes) from all arb files, preserving original indentation - `export_unused_terms.dart` - Writes unused keys to `unused-translations.txt` - `subpackage_detection.dart` - Detects subpackages (directories with pubspec.yaml) to exclude from scanning +- `term_usage_ast.dart` - AST visitor that decides whether a key is used, based on localization roots/accessors +- `config.dart` - Reads the optional `translations_cleaner:` section of the project's `pubspec.yaml` (`localization_roots`, `localization_accessors`) to extend the built-in root/accessor names - `models/term.dart` - `Term` class with key name and whether it has `@` attributes ### How Detection Works 1. Scan `.arb` files (excluding subpackages by default), extract non-`@` keys as translation terms 2. Scan all `lib/**.dart` files -3. For each term, check if `term.key` matches as a whole word (using `\b` word boundaries) in any dart file +3. Parse each Dart file with the analyzer and count a key as used only when it appears as a member on a localization-like target (e.g. `context.l10n.someKey`, `AppLocalizations.of(context).someKey`) or as a bare top-level call of the same name 4. Terms not found in any dart file are considered unused + +Root/accessor names come from built-in defaults plus anything listed under `translations_cleaner:` in the project's `pubspec.yaml` (see `config.dart`). Note the analyzer constraint spans 8.x–10.x, so AST code must use APIs present across that range — e.g. `NamedType.name`, since `name2` was removed in analyzer 10. diff --git a/README.md b/README.md index 7381eb2..5ef9127 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,59 @@ Checking for unused translations is tedious, hence this package. Unused keys used to be detected by scanning Dart files as raw text with word-boundary regex. That often marked keys as "used" when they only appeared in comments, string literals, or generated code (e.g. `AppLocalizations` getters). Detection is now **AST-based**: we parse each Dart file and only count a key as used when it appears as a member on a localization-like target (e.g. `context.l10n.someKey`, `AppLocalizations.of(context).someKey`) or as a top-level function call with the same name. That reduces false positives and aligns results with actual usage. +### Custom localization roots + +The AST scan recognises the common localization roots (`AppLocalizations`, `S`, `L10n`, `I18n`) and accessors (`l10n`, `loc`, `strings`, `translations`, …) out of the box. If your project wraps localizations in its own class — say `R` — add a `translations_cleaner:` section to your project's `pubspec.yaml`: + +```yaml +translations_cleaner: + localization_roots: + - R + localization_accessors: + - copy +``` + +- `localization_roots` — class names that hold translations, e.g. `R.instance.welcomeTitle` or `R.of(context).welcomeTitle`. +- `localization_accessors` — property/getter names that resolve to a localization object, e.g. `context.copy.welcomeTitle`. + +Both lists **extend** the built-in defaults rather than replacing them, and both are optional. Declaring a root also picks up values typed with it, so keys stay detected when the object is passed around: + +```dart +final S s = R.instance; // local variable typed as a root +String title(S s) => s.welcomeTitle; // parameter typed as a root +login.S.of(context).welcomeTitle; // root reached through a prefixed import +``` + +Without this config a custom root is **not** guessed at — its keys would be reported as unused and then deleted by `clean-translations`. + +### Scanned paths and ignored keys + +The same `translations_cleaner:` section accepts two more optional settings: + +```yaml +translations_cleaner: + source_paths: + - lib + - test + ignore_keys: + - dynamicKey +``` + +- `source_paths` — directories scanned for key usage, relative to the project root. Defaults to `[lib]`; add `test` to keep keys that are used only in tests. +- `ignore_keys` — keys that are always kept, for usages the syntax scan cannot see (generated code, dynamic lookups, or keys referenced outside `source_paths`). + ### Performance - **Regex (old):** Single pass over file contents with precompiled patterns; very fast but prone to false positives. - **AST (current):** Each Dart file is parsed once; more work per file but only `lib/**/*.dart` is scanned. On large projects you may notice a few extra seconds; `list-unused-terms` prints timing so you can compare. +### Known limitations + +The AST scan is syntax-only — it does not resolve types — so a few patterns are out of reach: + +- Local alias names are tracked for the whole file. An earlier `S s = ...` alias and a later, unrelated `Other s` sharing the same variable name can produce a false positive. +- Untyped locals and casts are not detected, e.g. `final s = S.of(ctx); s.key`. This is expected for a syntax-only scan; typed aliases (`final S s = S.of(ctx)`) cover the common case. + ## Limitations 😔 - This package currently works only for l10n achieved via `flutter_localizations`, which uses `.arb` files. diff --git a/lib/src/config.dart b/lib/src/config.dart new file mode 100644 index 0000000..aba8896 --- /dev/null +++ b/lib/src/config.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// Config read from the `translations_cleaner:` section of the project's +/// `pubspec.yaml`. +class TranslationsCleanerConfig { + const TranslationsCleanerConfig({ + this.localizationRoots = const {}, + this.localizationAccessors = const {}, + this.sourcePaths = const ['lib'], + this.ignoreKeys = const {}, + }); + + /// Extra localization root class names, added to the built-in defaults + final Set localizationRoots; + + /// Extra accessor names that resolve to a localization object + final Set localizationAccessors; + + /// Project directories scanned for key usage, relative to the project root. + /// Defaults to `lib` only; add `test` to keep keys used exclusively in tests. + final List sourcePaths; + + /// Keys that are always kept, for usages the syntax scan cannot see + /// (generated code, dynamic lookups, keys referenced outside [sourcePaths]). + final Set ignoreKeys; + + /// Reads the config from `/pubspec.yaml`, defaulting to empty + /// when the file or section is absent. + static TranslationsCleanerConfig load({String? projectRoot}) { + final root = projectRoot ?? Directory.current.path; + final file = File(p.join(root, 'pubspec.yaml')); + if (!file.existsSync()) { + return const TranslationsCleanerConfig(); + } + + Object? parsed; + try { + parsed = loadYaml(file.readAsStringSync()); + } catch (_) { + // Malformed YAML or a read failure falls back to the empty default + // rather than crashing the command. + return const TranslationsCleanerConfig(); + } + if (parsed is! YamlMap) { + return const TranslationsCleanerConfig(); + } + + final Object? section = parsed['translations_cleaner']; + if (section is! YamlMap) { + return const TranslationsCleanerConfig(); + } + + return TranslationsCleanerConfig( + localizationRoots: _stringSet(section['localization_roots']), + localizationAccessors: _stringSet(section['localization_accessors']), + sourcePaths: _stringList(section['source_paths'], const ['lib']), + ignoreKeys: _stringSet(section['ignore_keys']), + ); + } + + static Set _stringSet(Object? value) { + if (value is String) { + return {value}; + } + if (value is YamlList) { + return value.whereType().toSet(); + } + return const {}; + } + + static List _stringList(Object? value, List fallback) { + if (value is String) { + return [value]; + } + if (value is YamlList) { + final values = value.whereType().toList(); + if (values.isNotEmpty) { + return values; + } + } + return fallback; + } +} diff --git a/lib/src/project_files.dart b/lib/src/project_files.dart index 20b28cd..9dd8352 100644 --- a/lib/src/project_files.dart +++ b/lib/src/project_files.dart @@ -3,13 +3,20 @@ import 'dart:io'; import 'package:glob/glob.dart'; import 'package:glob/list_local_fs.dart'; -/// Get a list of all `.dart` files in the project -List getDartFiles() { - final path = Directory.current.path; - final dartFile = Glob("$path/lib/**.dart"); +/// Get a list of all `.dart` files under the given [sourcePaths]. +List getDartFiles({ + List sourcePaths = const ['lib'], +}) { + final root = Directory.current.path; final dartFiles = []; - for (var entity in dartFile.listSync(followLinks: false)) { - dartFiles.add(entity); + final seen = {}; + for (final sourcePath in sourcePaths) { + final glob = Glob("$root/$sourcePath/**.dart"); + for (final entity in glob.listSync(followLinks: false)) { + if (seen.add(entity.path)) { + dartFiles.add(entity); + } + } } return dartFiles; diff --git a/lib/src/term_usage_ast.dart b/lib/src/term_usage_ast.dart index 176c6f2..4c2a7c8 100644 --- a/lib/src/term_usage_ast.dart +++ b/lib/src/term_usage_ast.dart @@ -7,12 +7,17 @@ import 'package:analyzer/error/error.dart'; /// Returns used translation keys from Dart files using a syntax-only scan. /// +/// [extraRoots] and [extraAccessors] extend the built-in localization roots and +/// accessor names for projects with a custom localization wrapper. +/// /// Returns `null` when parsing fails for any file so callers can decide how to /// handle the failure. Set? findUsedTermsWithAst( List dartFiles, - Set candidateKeys, -) { + Set candidateKeys, { + Set extraRoots = const {}, + Set extraAccessors = const {}, +}) { final usedKeys = {}; for (final file in dartFiles) { @@ -35,6 +40,8 @@ Set? findUsedTermsWithAst( _TranslationUsageVisitor( candidateKeys: candidateKeys, usedKeys: usedKeys, + extraRoots: extraRoots, + extraAccessors: extraAccessors, ), ); } @@ -46,6 +53,8 @@ Set? findUsedTermsWithAst( Set findUsedTermsInSourceWithAst( String source, { required Set candidateKeys, + Set extraRoots = const {}, + Set extraAccessors = const {}, }) { final parseResult = parseString( content: source, @@ -57,6 +66,8 @@ Set findUsedTermsInSourceWithAst( _TranslationUsageVisitor( candidateKeys: candidateKeys, usedKeys: usedKeys, + extraRoots: extraRoots, + extraAccessors: extraAccessors, ), ); @@ -67,12 +78,21 @@ class _TranslationUsageVisitor extends RecursiveAstVisitor { _TranslationUsageVisitor({ required this.candidateKeys, required this.usedKeys, - }); + Set extraRoots = const {}, + Set extraAccessors = const {}, + }) : _rootNames = {..._defaultRootNames, ...extraRoots}, + _accessorNames = {..._defaultAccessorNames, ...extraAccessors}; final Set candidateKeys; final Set usedKeys; - static const Set _localizationAccessorNames = { + final Set _rootNames; + final Set _accessorNames; + + // Local variable names declared with a root type (`final S s = R.instance;`). + final Set _localAliasNames = {}; + + static const Set _defaultAccessorNames = { 'l10n', 'loc', 'locale', @@ -83,13 +103,36 @@ class _TranslationUsageVisitor extends RecursiveAstVisitor { 'current', }; - static const Set _localizationRootNames = { + static const Set _defaultRootNames = { 'AppLocalizations', 'S', 'L10n', 'I18n', }; + @override + void visitVariableDeclarationList(VariableDeclarationList node) { + final type = node.type; + if (type is NamedType && _rootNames.contains(type.name.lexeme)) { + for (final variable in node.variables) { + _localAliasNames.add(variable.name.lexeme); + } + } + super.visitVariableDeclarationList(node); + } + + @override + void visitSimpleFormalParameter(SimpleFormalParameter node) { + final type = node.type; + final name = node.name; + if (name != null && + type is NamedType && + _rootNames.contains(type.name.lexeme)) { + _localAliasNames.add(name.lexeme); + } + super.visitSimpleFormalParameter(node); + } + @override void visitPropertyAccess(PropertyAccess node) { final key = node.propertyName.name; @@ -133,31 +176,41 @@ class _TranslationUsageVisitor extends RecursiveAstVisitor { } if (expression is SimpleIdentifier) { - return _localizationAccessorNames.contains(expression.name); + return _accessorNames.contains(expression.name) || + _localAliasNames.contains(expression.name); } if (expression is PrefixedIdentifier) { - return _localizationAccessorNames.contains(expression.identifier.name) || - _localizationAccessorNames.contains(expression.prefix.name) || + return _rootNames.contains(expression.prefix.name) || + _accessorNames.contains(expression.identifier.name) || + _accessorNames.contains(expression.prefix.name) || _isLikelyLocalizationTarget(expression.prefix); } if (expression is PropertyAccess) { - return _localizationAccessorNames - .contains(expression.propertyName.name) || + return _accessorNames.contains(expression.propertyName.name) || _isLikelyLocalizationTarget(expression.target); } if (expression is MethodInvocation) { final target = expression.target; - final isOfFactory = expression.methodName.name == 'of' && - target is SimpleIdentifier && - _localizationRootNames.contains(target.name); + final isOfFactory = + expression.methodName.name == 'of' && _isRootReference(target); return isOfFactory || - _localizationAccessorNames.contains(expression.methodName.name) || + _accessorNames.contains(expression.methodName.name) || _isLikelyLocalizationTarget(target); } return false; } + + bool _isRootReference(Expression? expression) { + if (expression is SimpleIdentifier) { + return _rootNames.contains(expression.name); + } + if (expression is PrefixedIdentifier) { + return _rootNames.contains(expression.identifier.name); + } + return false; + } } diff --git a/lib/src/unused_terms.dart b/lib/src/unused_terms.dart index d16fefa..caed13c 100644 --- a/lib/src/unused_terms.dart +++ b/lib/src/unused_terms.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:translations_cleaner/src/config.dart'; import 'package:translations_cleaner/src/models/term.dart'; import 'package:translations_cleaner/src/project_files.dart'; import 'package:translations_cleaner/src/term_usage_ast.dart'; @@ -10,21 +11,32 @@ import 'package:translations_cleaner/src/translation_terms.dart'; Set findUnusedTerms() { print('FETCHING ALL THE TRANSLATION TERMS 🌏'); final terms = getTranslationTerms(); + final config = TranslationsCleanerConfig.load(); print('FETCHING ALL THE DART FILES TO LOOK THROUGH 🏗'); - final dartFiles = getDartFiles(); + final dartFiles = getDartFiles(sourcePaths: config.sourcePaths); print('LOOKING THROUGH FILES TO FIND UNUSED TERMS 👀'); final termKeys = terms.map((term) => term.key).toSet(); - final usedTerms = _findUsedTermsByAst(dartFiles, termKeys); + final usedTerms = _findUsedTermsByAst(dartFiles, termKeys, config); - return terms.where((term) => !usedTerms.contains(term.key)).toSet(); + return terms + .where((term) => + !usedTerms.contains(term.key) && + !config.ignoreKeys.contains(term.key)) + .toSet(); } Set _findUsedTermsByAst( List dartFiles, Set termKeys, + TranslationsCleanerConfig config, ) { - final usedKeys = findUsedTermsWithAst(dartFiles, termKeys); + final usedKeys = findUsedTermsWithAst( + dartFiles, + termKeys, + extraRoots: config.localizationRoots, + extraAccessors: config.localizationAccessors, + ); if (usedKeys == null) { throw StateError('AST parsing failed in at least one file.'); } diff --git a/pubspec.lock b/pubspec.lock index 3775d1f..e57c5f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -386,7 +386,7 @@ packages: source: hosted version: "1.2.1" yaml: - dependency: transitive + dependency: "direct main" description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce diff --git a/pubspec.yaml b/pubspec.yaml index ff6120d..c52e5fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: translations_cleaner description: Package to remove unused keys in arb files, across all translations -version: 0.2.1 +version: 0.3.0 repository: https://github.com/Chinmay-KB/translations_cleaner environment: @@ -11,6 +11,7 @@ dependencies: analyzer: ">=8.0.0 <11.0.0" glob: ^2.1.0 path: ^1.8.0 + yaml: ^3.1.2 dev_dependencies: lints: ^2.0.0 diff --git a/test/config_test.dart b/test/config_test.dart new file mode 100644 index 0000000..6a798c5 --- /dev/null +++ b/test/config_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:translations_cleaner/src/config.dart'; + +void main() { + group('TranslationsCleanerConfig.load', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('config_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + void writePubspec(String contents) { + File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync(contents); + } + + test('reads list-form settings', () { + writePubspec(''' +name: example +translations_cleaner: + localization_roots: + - R + - S + localization_accessors: + - copy + source_paths: + - lib + - test + ignore_keys: + - dynamicKey +'''); + + final config = TranslationsCleanerConfig.load(projectRoot: tempDir.path); + + expect(config.localizationRoots, equals({'R', 'S'})); + expect(config.localizationAccessors, equals({'copy'})); + expect(config.sourcePaths, equals(['lib', 'test'])); + expect(config.ignoreKeys, equals({'dynamicKey'})); + }); + + test('coerces bare scalar values into single-element collections', () { + writePubspec(''' +name: example +translations_cleaner: + localization_roots: R + ignore_keys: foo + source_paths: test +'''); + + final config = TranslationsCleanerConfig.load(projectRoot: tempDir.path); + + expect(config.localizationRoots, equals({'R'})); + expect(config.ignoreKeys, equals({'foo'})); + expect(config.sourcePaths, equals(['test'])); + }); + + test('returns defaults when the section is absent', () { + writePubspec('name: example\n'); + + final config = TranslationsCleanerConfig.load(projectRoot: tempDir.path); + + expect(config.localizationRoots, isEmpty); + expect(config.localizationAccessors, isEmpty); + expect(config.ignoreKeys, isEmpty); + expect(config.sourcePaths, equals(['lib'])); + }); + + test('returns defaults without throwing on malformed YAML', () { + writePubspec('name: example\n : : broken\n:::not valid yaml'); + + final config = TranslationsCleanerConfig.load(projectRoot: tempDir.path); + + expect(config.localizationRoots, isEmpty); + expect(config.localizationAccessors, isEmpty); + expect(config.ignoreKeys, isEmpty); + expect(config.sourcePaths, equals(['lib'])); + }); + + test('returns defaults when pubspec.yaml is missing', () { + final config = TranslationsCleanerConfig.load(projectRoot: tempDir.path); + + expect(config.localizationRoots, isEmpty); + expect(config.localizationAccessors, isEmpty); + expect(config.ignoreKeys, isEmpty); + expect(config.sourcePaths, equals(['lib'])); + }); + }); +} diff --git a/test/fixtures/ast_detection/ignore_keys_project/app_en.arb b/test/fixtures/ast_detection/ignore_keys_project/app_en.arb new file mode 100644 index 0000000..f200a69 --- /dev/null +++ b/test/fixtures/ast_detection/ignore_keys_project/app_en.arb @@ -0,0 +1,5 @@ +{ + "usedInLib": "Lib", + "keptViaIgnore": "Kept", + "reallyUnused": "Nope" +} diff --git a/test/fixtures/ast_detection/ignore_keys_project/lib/main.dart b/test/fixtures/ast_detection/ignore_keys_project/lib/main.dart new file mode 100644 index 0000000..d8d23a8 --- /dev/null +++ b/test/fixtures/ast_detection/ignore_keys_project/lib/main.dart @@ -0,0 +1,3 @@ +void render(context) { + final a = S.of(context).usedInLib; +} diff --git a/test/fixtures/ast_detection/ignore_keys_project/pubspec.yaml b/test/fixtures/ast_detection/ignore_keys_project/pubspec.yaml new file mode 100644 index 0000000..f75cb2c --- /dev/null +++ b/test/fixtures/ast_detection/ignore_keys_project/pubspec.yaml @@ -0,0 +1,3 @@ +name: ignore_keys_project +translations_cleaner: + ignore_keys: [keptViaIgnore] diff --git a/test/fixtures/ast_detection/source_paths_project/app_en.arb b/test/fixtures/ast_detection/source_paths_project/app_en.arb new file mode 100644 index 0000000..ed308e2 --- /dev/null +++ b/test/fixtures/ast_detection/source_paths_project/app_en.arb @@ -0,0 +1,5 @@ +{ + "usedInLib": "Lib", + "usedInTest": "Test", + "reallyUnused": "Nope" +} diff --git a/test/fixtures/ast_detection/source_paths_project/lib/main.dart b/test/fixtures/ast_detection/source_paths_project/lib/main.dart new file mode 100644 index 0000000..d8d23a8 --- /dev/null +++ b/test/fixtures/ast_detection/source_paths_project/lib/main.dart @@ -0,0 +1,3 @@ +void render(context) { + final a = S.of(context).usedInLib; +} diff --git a/test/fixtures/ast_detection/source_paths_project/pubspec.yaml b/test/fixtures/ast_detection/source_paths_project/pubspec.yaml new file mode 100644 index 0000000..327fc90 --- /dev/null +++ b/test/fixtures/ast_detection/source_paths_project/pubspec.yaml @@ -0,0 +1,3 @@ +name: source_paths_project +translations_cleaner: + source_paths: [lib, test] diff --git a/test/fixtures/ast_detection/source_paths_project/test/widget_usage.dart b/test/fixtures/ast_detection/source_paths_project/test/widget_usage.dart new file mode 100644 index 0000000..34de093 --- /dev/null +++ b/test/fixtures/ast_detection/source_paths_project/test/widget_usage.dart @@ -0,0 +1,3 @@ +void main() { + final b = S.of(context).usedInTest; +} diff --git a/test/fixtures/ast_detection/test_default_project/app_en.arb b/test/fixtures/ast_detection/test_default_project/app_en.arb new file mode 100644 index 0000000..ed308e2 --- /dev/null +++ b/test/fixtures/ast_detection/test_default_project/app_en.arb @@ -0,0 +1,5 @@ +{ + "usedInLib": "Lib", + "usedInTest": "Test", + "reallyUnused": "Nope" +} diff --git a/test/fixtures/ast_detection/test_default_project/lib/main.dart b/test/fixtures/ast_detection/test_default_project/lib/main.dart new file mode 100644 index 0000000..d8d23a8 --- /dev/null +++ b/test/fixtures/ast_detection/test_default_project/lib/main.dart @@ -0,0 +1,3 @@ +void render(context) { + final a = S.of(context).usedInLib; +} diff --git a/test/fixtures/ast_detection/test_default_project/test/widget_usage.dart b/test/fixtures/ast_detection/test_default_project/test/widget_usage.dart new file mode 100644 index 0000000..34de093 --- /dev/null +++ b/test/fixtures/ast_detection/test_default_project/test/widget_usage.dart @@ -0,0 +1,3 @@ +void main() { + final b = S.of(context).usedInTest; +} diff --git a/test/term_usage_ast_test.dart b/test/term_usage_ast_test.dart index 42abc9e..d7b18fb 100644 --- a/test/term_usage_ast_test.dart +++ b/test/term_usage_ast_test.dart @@ -159,6 +159,119 @@ void main(BuildContext context) { ); expect(used, isEmpty); }); + + test('detects a custom localization root supplied via extraRoots', () { + const source = ''' +class Service { + String title() => R.instance.welcomeTitle; + String greeting() => R.instance.greetingWithName('Sam'); +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle', 'greetingWithName'}, + extraRoots: {'R'}, + ); + + expect(used, equals({'welcomeTitle', 'greetingWithName'})); + }); + + test('a custom root is opt-in and ignored without configuration', () { + const source = ''' +class Service { + String title() => R.instance.welcomeTitle; +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle'}, + ); + + expect(used, isEmpty); + }); + + test('detects usages through a local alias typed as a root', () { + const source = ''' +class Service { + void build() { + final S s = R.instance; + final title = s.welcomeTitle; + final greeting = s.greetingWithName('Sam'); + } +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle', 'greetingWithName'}, + ); + + expect(used, containsAll(['welcomeTitle', 'greetingWithName'])); + }); + + test('detects usages through a parameter typed as a root', () { + const source = ''' +class Service { + String title(S s) => s.welcomeTitle; + String greeting({required S s}) => s.greetingWithName('Sam'); +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle', 'greetingWithName'}, + ); + + expect(used, containsAll(['welcomeTitle', 'greetingWithName'])); + }); + + test('detects a custom accessor supplied via extraAccessors', () { + const source = ''' +class Widget { + String build(BuildContext context) => context.copy.welcomeTitle; +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle'}, + extraAccessors: {'copy'}, + ); + + expect(used, equals({'welcomeTitle'})); + }); + + test('a custom accessor is opt-in and ignored without configuration', () { + const source = ''' +class Widget { + String build(BuildContext context) => context.copy.welcomeTitle; +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle'}, + ); + + expect(used, isEmpty); + }); + + test('detects the of factory through a prefixed import', () { + const source = ''' +class Widget { + String build(BuildContext context) => login.S.of(context).welcomeTitle; +} +'''; + + final used = findUsedTermsInSourceWithAst( + source, + candidateKeys: {'welcomeTitle'}, + ); + + expect(used, equals({'welcomeTitle'})); + }); }); group('findUsedTermsWithAst', () { diff --git a/test/unused_terms_detection_test.dart b/test/unused_terms_detection_test.dart index b822cea..48efc87 100644 --- a/test/unused_terms_detection_test.dart +++ b/test/unused_terms_detection_test.dart @@ -53,6 +53,33 @@ void main() { equals({'shortcutKey', 'dynamicKey', 'reallyUnused'}), ); }); + + test('only lib is scanned by default, so test-only keys are unused', () { + final unused = _runInFixtureProject( + 'test_default_project', + () => findUnusedTerms(), + ); + + expect(_unusedKeys(unused), equals({'usedInTest', 'reallyUnused'})); + }); + + test('source_paths can add test so test-only keys are kept', () { + final unused = _runInFixtureProject( + 'source_paths_project', + () => findUnusedTerms(), + ); + + expect(_unusedKeys(unused), equals({'reallyUnused'})); + }); + + test('ignore_keys keeps keys the scan cannot see', () { + final unused = _runInFixtureProject( + 'ignore_keys_project', + () => findUnusedTerms(), + ); + + expect(_unusedKeys(unused), equals({'reallyUnused'})); + }); }); }