magic_value_extractor 0.1.0 copy "magic_value_extractor: ^0.1.0" to clipboard
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 #

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 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.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-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, 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.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

0
likes
160
points
18
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Extracts hard-coded magic strings and numbers from Dart and Flutter sources into AppStrings / AppNumbers constants and rewrites the call sites for you.

Repository (GitHub)
View/report issues

Topics

#cli #refactoring #constants #codegen #i18n

License

MIT (license)

Dependencies

analyzer, args, path

More

Packages that depend on magic_value_extractor