Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions lib/src/config.dart
Original file line number Diff line number Diff line change
@@ -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 <String>{},
this.localizationAccessors = const <String>{},
this.sourcePaths = const <String>['lib'],
this.ignoreKeys = const <String>{},
});

/// Extra localization root class names, added to the built-in defaults
final Set<String> localizationRoots;

/// Extra accessor names that resolve to a localization object
final Set<String> 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<String> sourcePaths;

/// Keys that are always kept, for usages the syntax scan cannot see
/// (generated code, dynamic lookups, keys referenced outside [sourcePaths]).
final Set<String> ignoreKeys;

/// Reads the config from `<projectRoot>/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'));
Comment thread
dilumdesilva marked this conversation as resolved.
Resolved
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 <String>['lib']),
ignoreKeys: _stringSet(section['ignore_keys']),
);
}

static Set<String> _stringSet(Object? value) {
if (value is String) {
return {value};
}
if (value is YamlList) {
return value.whereType<String>().toSet();
}
return const <String>{};
}

static List<String> _stringList(Object? value, List<String> fallback) {
if (value is String) {
return [value];
}
if (value is YamlList) {
final values = value.whereType<String>().toList();
if (values.isNotEmpty) {
return values;
}
}
return fallback;
}
}
19 changes: 13 additions & 6 deletions lib/src/project_files.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileSystemEntity> getDartFiles() {
final path = Directory.current.path;
final dartFile = Glob("$path/lib/**.dart");
/// Get a list of all `.dart` files under the given [sourcePaths].
List<FileSystemEntity> getDartFiles({
List<String> sourcePaths = const <String>['lib'],
}) {
final root = Directory.current.path;
final dartFiles = <FileSystemEntity>[];
for (var entity in dartFile.listSync(followLinks: false)) {
dartFiles.add(entity);
final seen = <String>{};
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;
Expand Down
81 changes: 67 additions & 14 deletions lib/src/term_usage_ast.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? findUsedTermsWithAst(
List<FileSystemEntity> dartFiles,
Set<String> candidateKeys,
) {
Set<String> candidateKeys, {
Set<String> extraRoots = const <String>{},
Set<String> extraAccessors = const <String>{},
}) {
final usedKeys = <String>{};

for (final file in dartFiles) {
Expand All @@ -35,6 +40,8 @@ Set<String>? findUsedTermsWithAst(
_TranslationUsageVisitor(
candidateKeys: candidateKeys,
usedKeys: usedKeys,
extraRoots: extraRoots,
extraAccessors: extraAccessors,
),
);
}
Expand All @@ -46,6 +53,8 @@ Set<String>? findUsedTermsWithAst(
Set<String> findUsedTermsInSourceWithAst(
String source, {
required Set<String> candidateKeys,
Set<String> extraRoots = const <String>{},
Set<String> extraAccessors = const <String>{},
}) {
final parseResult = parseString(
content: source,
Expand All @@ -57,6 +66,8 @@ Set<String> findUsedTermsInSourceWithAst(
_TranslationUsageVisitor(
candidateKeys: candidateKeys,
usedKeys: usedKeys,
extraRoots: extraRoots,
extraAccessors: extraAccessors,
),
);

Expand All @@ -67,12 +78,21 @@ class _TranslationUsageVisitor extends RecursiveAstVisitor<void> {
_TranslationUsageVisitor({
required this.candidateKeys,
required this.usedKeys,
});
Set<String> extraRoots = const <String>{},
Set<String> extraAccessors = const <String>{},
}) : _rootNames = {..._defaultRootNames, ...extraRoots},
_accessorNames = {..._defaultAccessorNames, ...extraAccessors};

final Set<String> candidateKeys;
final Set<String> usedKeys;

static const Set<String> _localizationAccessorNames = {
final Set<String> _rootNames;
final Set<String> _accessorNames;

// Local variable names declared with a root type (`final S s = R.instance;`).
final Set<String> _localAliasNames = <String>{};

static const Set<String> _defaultAccessorNames = {
'l10n',
'loc',
'locale',
Expand All @@ -83,13 +103,36 @@ class _TranslationUsageVisitor extends RecursiveAstVisitor<void> {
'current',
};

static const Set<String> _localizationRootNames = {
static const Set<String> _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;
Expand Down Expand Up @@ -133,31 +176,41 @@ class _TranslationUsageVisitor extends RecursiveAstVisitor<void> {
}

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;
}
}
Loading
Loading