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
Libraries
- magic_value_extractor
- Extracts hard-coded magic strings and magic numbers from Dart / Flutter sources into constant classes and rewrites the original call sites.