magic_value_extractor 0.1.0
magic_value_extractor: ^0.1.0 copied to clipboard
Extracts hard-coded magic strings and numbers from Dart and Flutter sources into AppStrings / AppNumbers constants and rewrites the call sites for you.
magic_value_extractor #
A CLI tool that finds hard-coded magic strings and magic numbers in Dart /
Flutter sources, collects them into constant classes (AppStrings /
AppNumbers), and rewrites the original call sites into commented constant
references.
dart run magic_value_extractor --target=lib/ --output=lib/constants/
日本語版は README.ja.md にあります。
- Works on the analyzer AST, so nothing inside comments or strings is ever mistaken for a literal. This is not regex search-and-replace.
- Rewrites only the literal ranges, so not a single other byte moves. Your formatting is left exactly as it was.
- Idempotent: re-running changes nothing. Existing constants are matched by value, so a constant you renamed in your IDE is reused instead of duplicated.
- Every rewritten file is re-parsed before it is written; if a change would break the file, the file is left untouched and reported.
Contents #
- Installation
- Compatibility
- Usage
- Before / after
- Generated constant files
- What is extracted, what is excluded
- Naming rules
- Translation hook (DeepL, LLM, glossary)
- Re-runs and manual edits
- int vs double (important)
- Options
- Limitations
- Development
Installation #
Add it as a dev_dependency (not published to pub.flutter-io.cn yet, so use a git or
path dependency):
dev_dependencies:
magic_value_extractor:
git:
url: https://github.com/FuruyamaMasayuki/magic_to_const.git
dart pub get
dart run magic_value_extractor --help
You can also clone this repository and point it at another project, which leaves that project's dependencies untouched:
dart run bin/magic_value_extractor.dart \
--project-root=../my_flutter_app --target=lib/ --output=lib/constants/
Compatibility #
| Item | Requirement |
|---|---|
| Dart SDK | 3.5 or newer |
| Flutter | 3.24 or newer (the release that ships Dart 3.5) |
| Dependencies | analyzer >=7.4.0 <15.0.0, args, path only — no Flutter dependency |
The analyzer range is deliberately wide. This package is added as a
dev_dependency, and a narrow constraint here would drag the host project's
build_runner / json_serializable down to older versions — or make
dart pub get fail outright.
To keep that range, the code avoids every AST member that was renamed between
analyzer major versions (NamedExpression → NamedArgument,
ClassDeclaration.members → body, NamedType.name2 → name, …) and reads the
token stream plus toSource() instead. See lib/src/util/ast_compat.dart.
Which analyzer version a consumer ends up with is pub's choice, based on the SDK and the other dependencies:
| Consumer environment | Resolved analyzer |
|---|---|
| Dart 3.5 (Flutter 3.24) | 7.7.1 |
Dart 3.12 + current codegen (json_serializable 6.14) |
13.3.0 |
| Dart 3.12, nothing else constraining it | 14.1.0 |
Verification status:
- Compiles (
dart analyzeclean) against analyzer 7.4.0, 7.7.1, 8.4.1, 9.0.0, 10.0.0, 11.0.0, 12.0.0, 13.3.0 and 14.1.0. - Test suite green on Dart 3.5.4 (analyzer 7.7.1) and on analyzer 8.4.1, 10.0.0, 13.3.0 and 14.1.0. CI covers both ends of the range.
- The
dependencies-up-to-dateCI job (anddart run tool/check_dependencies.dart) fails when a constraint in this package holds a dependency below its latest release.
Usage #
# 1. See what would change – nothing is written
dart run magic_value_extractor --target=lib/ --dry-run --verbose
# 2. Tune the exclusions until the plan looks right
dart run magic_value_extractor --target=lib/ --dry-run \
--min-occurrences=2 --japanese-only
# 3. Apply it
dart run magic_value_extractor --target=lib/ --output=lib/constants/
# 4. Check the result
dart analyze && dart format lib/ && git diff
Tip: run it on a clean git tree. Then
git diffshows exactly what happened andgit checkout -- <file>undoes anything you dislike.
Example output:
magic_value_extractor
────────────────────────────────────────────────────────────
scanned files : 4
magic values : 16 (strings 6 / numbers 10)
occurrences : 17 (new constants 16, reused 0)
skipped literals : 14
2 assert()
2 empty string
2 annotation
1 URL / URI
1 regular expression
1 logging call
1 hex colour code
1 collection index
1 widget key
1 asset path
1 magic:ignore comment
magic:ignore-file : 1
wrote lib/constants/app_strings.dart (+6 constants)
wrote lib/constants/app_numbers.dart (+10 constants)
rewritten files : 3
--json-report=build/mve.json writes the same information as JSON, for CI.
Before / after #
Before #
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16.0),
// ログインボタンのコンテナ
child: Text('ログインに失敗しました'),
);
}
}
After #
import 'package:my_app/constants/app_numbers.dart';
import 'package:my_app/constants/app_strings.dart';
import 'package:flutter/material.dart';
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(AppNumbers.double_16_0), // 16.0
// ログインボタンのコンテナ
child: Text(AppStrings.loginFailed), // 'ログインに失敗しました'
);
}
}
- New imports are inserted in
dart:→package:→ relative order, so thedirectives_orderinglint stays happy. - The original value survives as a trailing comment. Several values on one line share a single comment.
- If the line already has a
//comment, a block comment is used instead so nothing gets swallowed:AppStrings.loginFailed /* 'ログインに失敗しました' */. - Choose the style with
--comment-style=line|block|none.
A runnable sample lives in example/.
Generated constant files #
Two files are created (or appended to) in --output:
lib/constants/app_strings.dart
/// Constants collected by `magic_value_extractor`.
class AppStrings {
const AppStrings._();
/// `'ログインに失敗しました'`
///
/// - lib/ui/home_page.dart:11:20 (HomePage.build / Text)
/// - lib/ui/login_screen.dart:11:19 (LoginScreen.build / Text)
static const String loginFailed = 'ログインに失敗しました';
}
lib/constants/app_numbers.dart
/// Constants collected by `magic_value_extractor`.
class AppNumbers {
const AppNumbers._();
/// `16.0`
///
/// - lib/ui/login_screen.dart:9:31 (LoginScreen.build / EdgeInsets.all)
static const double double_16_0 = 16.0;
/// `300`
///
/// - lib/api/auth_repository.dart:14:56 (AuthRepository.login / seconds)
static const int num_300 = 300;
}
- The fields are
static const, so they work inconstcontexts such asconst Text(...). - The doc comment records the original value and every call site, so you can navigate from the constant back to its usages.
- Class and file names are configurable:
--strings-class,--numbers-class,--strings-file,--numbers-file.
What is extracted, what is excluded #
Magic strings #
Display text is what this is for. The following are excluded by default, and each rule can be switched off on its own.
| Excluded | Example | Option |
|---|---|---|
| URLs / URIs | 'https://example.com', 'mailto:a@b.c' |
--no-exclude-urls |
| Hex colour codes | '#FF00FF', '0xFF2196F3' |
--no-exclude-hex-colors |
| Asset paths | 'assets/images/logo.png', 'fonts/Roboto.ttf' |
--no-exclude-asset-paths |
| File paths | '../lib/src/main', 'lib/src/widget' |
--no-exclude-file-paths |
| Empty / whitespace only | '', ' ' |
--no-exclude-empty |
Regular expressions and RegExp() arguments |
r'^\d{4}$' |
--no-exclude-regexps |
| Widget keys | Key('x'), key: ValueKey('y') |
--no-exclude-widget-keys |
| Logging | debugPrint('...'), logger.info('...') |
--no-exclude-log-calls |
Inside assert() |
assert(x, 'message') |
--no-exclude-asserts |
| Annotations | @Deprecated('...') |
--include-annotations |
import / export / part URIs |
import 'a.dart' |
always excluded |
| String interpolation | 'hello $name' |
always excluded – the meaning would change |
| Adjacent strings | 'one' 'two' |
always excluded – same reason |
| Identifier-like keys | 'userId', 'USER_ID', 'user.id' |
opt in with --exclude-identifiers |
| Too short / too long | 'a' |
--min-string-length / --max-string-length |
| Anything matching your regex | — | --exclude-string-pattern='^SKIP_' |
| Strings without Japanese characters | 'Save failed' |
opt in with --japanese-only |
Magic numbers #
Layout and logic values (EdgeInsets.all(16.0), Duration(seconds: 300), …)
are extracted.
| Excluded | Example | Option |
|---|---|---|
| Common basics | 0, 1, -1, 0.0, 1.0, -1.0 |
--ignore-numbers=0,1,-1,0.0,1.0,-1.0 |
| Hex literals (colours, bit masks) | 0xFF2196F3 |
--no-exclude-hex-colors |
| Collection indexes | items[7] |
--no-exclude-index-access |
Inside assert() / annotations |
assert(n > 3) |
as above |
Passing an integer to --ignore-numbers also ignores its double form
(--ignore-numbers=2 covers both 2 and 2.0).
A negative number is extracted together with its unary minus, as one constant
(-16.0 → double_minus_16_0).
Skipping files and lines #
- Generated files are excluded by default:
*.g.dart,*.freezed.dart,*.gr.dart,*.gen.dart,*.config.dart,*.mocks.dart,*.pb*.dart,generated_plugin_registrant.dart,**/generated/**,**/l10n/**,**/build/**,**/.dart_tool/**. Use--no-default-excludesto drop that list and--exclude='**/legacy/**'to add to it. // magic:ignoreskips its own line and the next one, so it works both as a trailing and as a leading comment.// magic:ignore-fileskips the whole file.- Files with a
part ofdirective cannot get their own imports, so they are reported and left alone.
const version = 'v1.0.0'; // magic:ignore
// magic:ignore
const buildFlavor = 'production';
Only extract repeated values #
--min-occurrences=2 extracts only values that appear at least twice, which is
a good way to introduce the tool into an existing codebase gradually.
Naming rules #
Strings #
Identifiers are lowerCamelCase, ASCII only. Japanese text is turned into
English in this order:
- The
--name-hookcommand (DeepL, an LLM, …; see below) - The built-in Japanese→English dictionary, with particles and verb endings dropped
- Kana→romaji transliteration (kanji are dropped)
--fallback-string-name(defaulttext) when nothing usable is left
'ログインに失敗しました' -> loginFailed (ログイン + 失敗; "に" and "しました" dropped)
'保存に失敗しました' -> saveFailed
'ホーム画面' -> homeScreen
'たくさん' -> takusan (not in the dictionary -> romaji)
'Save failed' -> saveFailed
-
--max-name-words(default 4) caps the length. -
Names that would start with a digit or collide with a Dart keyword are fixed automatically (
3件→text3Ken,class→classValue). -
Collisions get a numeric suffix:
loginFailed,loginFailed01,loginFailed02. -
Extend the dictionary with JSON:
dart run magic_value_extractor --dictionary=tool/glossary.json{ "重み": "weight", "会議": "meeting", "残高": "balance" }
Numbers #
Names are derived from the value (--number-name-style):
| Value | snake (default) |
camel |
|---|---|---|
16.0 |
double_16_0 |
double16p0 |
300 |
num_300 |
num300 |
-16.0 |
double_minus_16_0 |
doubleMinus16p0 |
0.75 |
double_0_75 |
double0p75 |
Prefixes are configurable through --int-name-prefix and
--double-name-prefix. snake violates the constant_identifier_names lint,
so the generated file starts with
// ignore_for_file: constant_identifier_names.
Translation hook (DeepL, LLM, glossary) #
--name-hook hands naming over to an external command.
- The literal text is passed both on stdin and as the last argument.
- The first line of stdout is used as the English phrase.
- An empty result or a non-zero exit code falls back to the built-in dictionary, so a missing API key can never break a run.
- The command is executed directly, not through a shell, so the literal can never be re-interpreted as shell syntax. Use a wrapper script if you need pipes or variable expansion.
--name-cache=.dart_tool/mve_names.jsoncaches the results, which keeps a paid API cheap and makes the names stable across runs.
export DEEPL_API_KEY=xxxxxxxx
dart run magic_value_extractor \
--name-hook='dart run tool/deepl_hook.dart' \
--name-cache=.dart_tool/mve_names.json
A working sample script is in tool/deepl_hook.dart;
the same shape works for a company glossary or an LLM.
From Dart, implement NameSource:
class MyTranslator implements NameSource {
@override
Future<String?> suggest(String text) async => await myApi.translate(text);
}
Re-runs and manual edits #
- Constant files are appended to. Your header comments, hand-written doc comments and manual ordering are preserved.
- Existing constants are matched by value, not by name. Rename
AppStrings.loginFailedtoAppStrings.loginErrorMessagein your IDE and the next run reuses that name instead of adding a duplicate. - The generated constant files themselves are never scanned.
- So the second and later runs only deal with literals that are actually new.
int vs double (important) #
The 8 in SizedBox(width: 8) is a double. Emitting it as static const int
would not compile, so an int literal becomes a double constant when:
- a type annotation says so (
const double x = 5;,<double>[4, 8],void f({double gap = 8})), or - the argument name or constructor is a known
doubleAPI (width,height,fontSize,elevation,EdgeInsets.all,Offset, …).
SizedBox(width: 8) -> SizedBox(width: AppNumbers.double_8_0) // double
Duration(seconds: 300) -> Duration(seconds: AppNumbers.num_300) // int
Text('x', maxLines: 2) -> Text('x', maxLines: AppNumbers.num_2) // int
- Add your own API with
--double-params=myPadding,mySpacing. - Turn the heuristic off with
--no-double-heuristic. - The tool does not resolve types (which is why it is fast and works without
pub get), so this part is a guess. Rundart analyzeafter a conversion.--verify=analyzeruns it for you and reports the result.
Options #
dart run magic_value_extractor --help lists everything. The important ones:
| Option | Default | Meaning |
|---|---|---|
--target, -t |
lib |
File or directory to scan (comma-separated, repeatable) |
--output, -o |
lib/constants |
Where the constant files go |
--project-root |
current directory | Root of the project to process |
--dry-run, -n |
off | Only show what would change |
--verbose, -v |
off | Also list exclusion reasons and every constant |
--no-rewrite |
— | Write the constant files but leave call sites alone |
--comment-style |
line |
line / block / none |
--import-style |
auto |
auto / package / relative |
--min-occurrences |
1 |
Only extract values seen at least N times |
--no-strings / --no-numbers |
— | Turn off strings or numbers |
--japanese-only |
off | Only strings containing Japanese characters |
--verify |
parse |
none / parse / analyze |
--json-report |
— | Write a JSON report |
--exclude |
— | Extra glob to skip |
--dictionary |
— | Extra Japanese→English dictionary (JSON) |
--name-hook / --name-cache |
— | Translation hook and its cache |
Exit codes: 0 success, 1 some files were left unchanged for safety, 64
bad arguments, 70 runtime error.
Limitations #
- Types are not resolved, so the
int/doubledecision is the heuristic described above. - String interpolation (
'... $x ...') and adjacent strings ('a' 'b') are out of scope because replacing them would change the meaning. Move those by hand if you want them inAppStrings. - Files containing
part ofare not rewritten, because the import cannot be added there. The report tells you how many were skipped; add the import to the owning library by hand if you need them covered. - Localisation (l10n / ARB) itself is out of scope. The intended path is to
collect values into constants first, then replace
AppStringswithAppLocalizations. - If a hand-edited constant file no longer parses, the tool reports an error and touches nothing.
Development #
dart pub get
dart analyze
dart test
dart run tool/check_dependencies.dart # constraints must not hold back deps
# Try it on the example, then reset
dart run bin/magic_value_extractor.dart --project-root=example/demo \
--target=lib/ --output=lib/constants/
(cd example/demo && dart pub get && dart analyze)
git checkout example/demo && git clean -fd example/demo
Release steps are in RELEASING.md.
| Path | Role |
|---|---|
bin/magic_value_extractor.dart |
CLI entry point |
lib/src/config/ |
Command line arguments and configuration |
lib/src/analysis/ |
File discovery, AST walking, exclusion rules |
lib/src/naming/ |
Japanese dictionary, romaji, translation hook, naming |
lib/src/generator/ |
Constant file rendering and merging |
lib/src/rewriter/ |
Call site rewriting and import insertion |
lib/src/util/ast_compat.dart |
Analyzer-version-independent AST access |
lib/src/runner.dart |
Orchestration |
License #
MIT