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

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 (NamedExpressionNamedArgument, ClassDeclaration.membersbody, NamedType.name2name, …) 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 analyze clean) 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-date CI job (and dart 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 diff shows exactly what happened and git 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 the directives_ordering lint 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 in const contexts such as const 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.0double_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-excludes to drop that list and --exclude='**/legacy/**' to add to it.
  • // magic:ignore skips its own line and the next one, so it works both as a trailing and as a leading comment.
  • // magic:ignore-file skips the whole file.
  • Files with a part of directive 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:

  1. The --name-hook command (DeepL, an LLM, …; see below)
  2. The built-in Japanese→English dictionary, with particles and verb endings dropped
  3. Kana→romaji transliteration (kanji are dropped)
  4. --fallback-string-name (default text) 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, classclassValue).

  • 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.json caches 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.loginFailed to AppStrings.loginErrorMessage in 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:

  1. a type annotation says so (const double x = 5;, <double>[4, 8], void f({double gap = 8})), or
  2. the argument name or constructor is a known double API (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. Run dart analyze after a conversion. --verify=analyze runs 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 / double decision 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 in AppStrings.
  • Files containing part of are 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 AppStrings with AppLocalizations.
  • 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.