Skip to content

Instantly share code, notes, and snippets.

@bizz84
Created June 10, 2026 16:44
Show Gist options
  • Select an option

  • Save bizz84/f31ab7ed9568f5df163759c290113666 to your computer and use it in GitHub Desktop.

Select an option

Save bizz84/f31ab7ed9568f5df163759c290113666 to your computer and use it in GitHub Desktop.
Prototype CLI for Dart primary constructor migration (built by GPT-5.5)
#!/usr/bin/env dart
import 'dart:io';
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:pub_semver/pub_semver.dart';
final _primaryConstructorFeatureSet = FeatureSet.fromEnableFlags2(
sdkLanguageVersion: Version(3, 12, 0),
flags: ['declaring-constructors'],
);
class Edit {
Edit(this.offset, this.length, this.replacement);
final int offset;
final int length;
final String replacement;
}
class FieldInfo {
FieldInfo({
required this.name,
required this.type,
required this.declaration,
required this.variable,
required this.isFinal,
required this.comment,
required this.removeStart,
});
final String name;
final String type;
final FieldDeclaration declaration;
final VariableDeclaration variable;
final bool isFinal;
final String? comment;
final int removeStart;
String get declaringKeyword => isFinal ? 'final' : 'var';
}
class FileResult {
FileResult({required this.path});
final String path;
final List<String> primaryClasses = [];
final List<String> primaryEnums = [];
final List<String> emptyClasses = [];
final List<String> shorthandConstructors = [];
bool get hasChanges =>
primaryClasses.isNotEmpty ||
primaryEnums.isNotEmpty ||
emptyClasses.isNotEmpty ||
shorthandConstructors.isNotEmpty;
}
void main(List<String> args) {
if (args.contains('--help') || args.contains('-h')) {
_printUsage();
return;
}
final dryRun = args.contains('--dry-run');
final root = _rootFromArgs(args);
if (!File('${root.path}/pubspec.yaml').existsSync()) {
stderr.writeln('No pubspec.yaml found at ${root.path}. Run from a package root or pass --root <path>.');
exitCode = 64;
return;
}
final results = <FileResult>[];
for (final file in _dartFiles(root)) {
final result = _migrateFile(file, root.path, dryRun: dryRun);
if (result != null && result.hasChanges) {
results.add(result);
}
}
if (results.isEmpty) {
stdout.writeln('No supported primary-constructor migrations found.');
return;
}
stdout.writeln(dryRun ? 'Dry run changes:' : 'Applied changes:');
for (final result in results) {
stdout.writeln(result.path);
if (result.primaryClasses.isNotEmpty) {
stdout.writeln(' primary classes: ${result.primaryClasses.join(', ')}');
}
if (result.primaryEnums.isNotEmpty) {
stdout.writeln(' primary enums: ${result.primaryEnums.join(', ')}');
}
if (result.emptyClasses.isNotEmpty) {
stdout.writeln(' empty classes: ${result.emptyClasses.join(', ')}');
}
if (result.shorthandConstructors.isNotEmpty) {
stdout.writeln(' shorthand constructors: ${result.shorthandConstructors.join(', ')}');
}
}
}
void _printUsage() {
stdout.writeln(
'''
Migrates supported Dart classes and enums to primary constructors.
Usage:
dart dart_migrate_primary_constructors_cli [--dry-run] [--root <path>]
Notes:
- Run from an analyzer-clean package that already enables primary-constructors.
- Skips generated files and unsupported constructor forms.
- Format changed files afterward with:
dart format --enable-experiment=primary-constructors <changed-files>
'''
.trim(),
);
}
Directory _rootFromArgs(List<String> args) {
final rootIndex = args.indexOf('--root');
if (rootIndex != -1 && rootIndex + 1 < args.length) {
return Directory(args[rootIndex + 1]).absolute;
}
return Directory.current.absolute;
}
Iterable<File> _dartFiles(Directory root) sync* {
for (final entity in root.listSync(recursive: true, followLinks: false)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final parts = entity.uri.pathSegments;
if (parts.contains('.dart_tool') || parts.contains('build')) continue;
yield entity;
}
}
FileResult? _migrateFile(File file, String rootPath, {required bool dryRun}) {
final path = file.path;
var source = file.readAsStringSync();
if (_isGenerated(path, source)) return null;
final CompilationUnit unit;
try {
unit = parseString(
content: source,
path: path,
featureSet: _primaryConstructorFeatureSet,
).unit;
} on ArgumentError {
if (_containsPrimaryConstructorSyntax(source)) return null;
rethrow;
}
final edits = <Edit>[];
final result = FileResult(path: _relative(path, rootPath));
for (final declaration in unit.declarations) {
if (declaration is ClassDeclaration) {
_migrateClass(source, declaration, edits, result);
} else if (declaration is EnumDeclaration) {
_migrateEnum(source, declaration, edits, result);
}
}
if (edits.isEmpty) return null;
source = _applyEdits(source, edits);
if (!dryRun) {
file.writeAsStringSync(source);
}
return result;
}
bool _isGenerated(String path, String source) {
return path.endsWith('.g.dart') ||
path.endsWith('.freezed.dart') ||
path.endsWith('.mocks.dart') ||
source.contains('GENERATED CODE - DO NOT MODIFY');
}
bool _containsPrimaryConstructorSyntax(String source) {
final classPattern = RegExp(
r'^\s*(?:(?:abstract|base|final|interface|sealed)\s+)*class\s+(?:const\s+)?[A-Za-z_]\w*(?:<[^>{}]*>)?\s*\(',
multiLine: true,
);
final enumPattern = RegExp(
r'^\s*enum\s+[A-Za-z_]\w*(?:<[^>{}]*>)?\s*\(',
multiLine: true,
);
final shorthandPattern = RegExp(
r'^\s*(?:const\s+)?new(?:\s+[A-Za-z_]\w*)?\s*\(',
multiLine: true,
);
return classPattern.hasMatch(source) || enumPattern.hasMatch(source) || shorthandPattern.hasMatch(source);
}
String _relative(String path, String rootPath) {
if (path.startsWith('$rootPath/')) return path.substring(rootPath.length + 1);
return path;
}
void _migrateClass(String source, ClassDeclaration node, List<Edit> edits, FileResult result) {
if (node.mixinKeyword != null) return;
final constructors = node.members.whereType<ConstructorDeclaration>().toList();
final generative = constructors.where((c) => c.factoryKeyword == null && c.externalKeyword == null).toList();
final nonRedirecting = generative.where((c) => !_isRedirectingGenerative(c)).toList();
final primaryCandidate = nonRedirecting.length == 1 && nonRedirecting.single.name == null
? nonRedirecting.single
: null;
if (primaryCandidate != null && _canMigratePrimaryConstructor(primaryCandidate)) {
if (_applyPrimaryClassMigration(source, node, primaryCandidate, edits)) {
result.primaryClasses.add(node.name.lexeme);
return;
}
}
if (constructors.isEmpty && node.members.isEmpty) {
edits.add(Edit(node.leftBracket.offset, node.rightBracket.end - node.leftBracket.offset, ';'));
result.emptyClasses.add(node.name.lexeme);
return;
}
for (final constructor in generative) {
if (_canUseConstructorShorthand(constructor)) {
_applyConstructorShorthand(constructor, edits);
result.shorthandConstructors.add(_constructorDisplayName(node.name.lexeme, constructor));
}
}
}
bool _canMigratePrimaryConstructor(ConstructorDeclaration constructor) {
if (constructor.metadata.isNotEmpty) return false;
if (constructor.externalKeyword != null || constructor.factoryKeyword != null) return false;
if (_hasParameterMetadata(constructor)) return false;
if (constructor.body is! EmptyFunctionBody) return false;
if (_containsNamedSuperInitializer(constructor)) return false;
if (_containsUnsupportedInitializer(constructor)) return false;
return true;
}
bool _applyPrimaryClassMigration(
String source,
ClassDeclaration node,
ConstructorDeclaration constructor,
List<Edit> edits,
) {
final fields = _fieldMap(source, node.members);
final declaringFieldNames = <String>{};
final privateParamToField = <String, String>{};
final fieldInitializers = <String, ConstructorFieldInitializer>{};
final retainedInitializers = <ConstructorInitializer>[];
final parameterNames = constructor.parameters.parameters.map((p) => p.name?.lexeme).whereType<String>().toSet();
for (final initializer in constructor.initializers) {
if (initializer is ConstructorFieldInitializer) {
final fieldName = initializer.fieldName.name;
final expressionSource = initializer.expression.toSource();
if (fieldName.startsWith('_') &&
expressionSource == fieldName.substring(1) &&
parameterNames.contains(expressionSource)) {
privateParamToField[expressionSource] = fieldName;
declaringFieldNames.add(fieldName);
continue;
}
if (!fields.containsKey(fieldName) || parameterNames.contains(fieldName)) return false;
fieldInitializers[fieldName] = initializer;
continue;
}
retainedInitializers.add(initializer);
}
final transformedParams = _transformedParameterList(
source,
constructor,
fields,
declaringFieldNames,
privateParamToField,
);
if (transformedParams == null) return false;
for (final fieldName in declaringFieldNames) {
if (!fields.containsKey(fieldName)) return false;
}
final removedMembers = <ClassMember>{constructor};
for (final name in declaringFieldNames) {
removedMembers.add(fields[name]!.declaration);
}
final thisInitializer = _primaryThisInitializer(source, retainedInitializers, constructor.body);
final remainingMembers =
node.members.where((m) => !removedMembers.contains(m)).length + (thisInitializer == null ? 0 : 1);
final header = _classHeader(source, node, constructor.constKeyword != null, transformedParams);
if (remainingMembers == 0) {
edits.add(Edit(node.offset, node.rightBracket.end - node.offset, '${header.trimRight()};'));
return true;
}
edits.add(Edit(node.offset, node.leftBracket.offset - node.offset, header));
for (final name in declaringFieldNames) {
final field = fields[name]!;
edits.add(_removeWholeMember(source, field.removeStart, field.declaration.end));
}
for (final entry in fieldInitializers.entries) {
final field = fields[entry.key];
if (field == null) return false;
edits.add(Edit(field.variable.end, 0, ' = ${entry.value.expression.toSource()}'));
}
if (thisInitializer == null) {
edits.add(_removeWholeMember(source, constructor.offset, constructor.end));
} else {
final indent = _lineIndent(source, constructor.offset);
edits.add(
Edit(
_lineStart(source, constructor.offset),
_lineEndIncludingNewline(source, constructor.end) - _lineStart(source, constructor.offset),
'$indent$thisInitializer\n',
),
);
}
return true;
}
void _migrateEnum(String source, EnumDeclaration node, List<Edit> edits, FileResult result) {
final constructors = node.members.whereType<ConstructorDeclaration>().toList();
final generative = constructors.where((c) => c.factoryKeyword == null && c.externalKeyword == null).toList();
final nonRedirecting = generative.where((c) => !_isRedirectingGenerative(c)).toList();
if (nonRedirecting.length != 1 || nonRedirecting.single.name != null) return;
final constructor = nonRedirecting.single;
if (!_canMigratePrimaryEnumConstructor(constructor)) return;
final fields = _fieldMap(source, node.members);
final declaringFieldNames = <String>{};
final transformedParams = _transformedParameterList(source, constructor, fields, declaringFieldNames, const {});
if (transformedParams == null) return;
for (final name in declaringFieldNames) {
if (!fields.containsKey(name)) return;
}
edits.add(Edit(node.offset, node.leftBracket.offset - node.offset, _enumHeader(source, node, transformedParams)));
for (final name in declaringFieldNames) {
final field = fields[name]!;
edits.add(_removeWholeMember(source, field.removeStart, field.declaration.end));
}
edits.add(_removeWholeMember(source, constructor.offset, constructor.end));
result.primaryEnums.add(node.name.lexeme);
}
bool _canMigratePrimaryEnumConstructor(ConstructorDeclaration constructor) {
if (constructor.metadata.isNotEmpty) return false;
if (constructor.externalKeyword != null || constructor.factoryKeyword != null) return false;
if (_hasParameterMetadata(constructor)) return false;
if (constructor.body is! EmptyFunctionBody) return false;
if (constructor.initializers.isNotEmpty) return false;
return true;
}
Map<String, FieldInfo> _fieldMap(String source, Iterable<ClassMember> members) {
final fields = <String, FieldInfo>{};
for (final member in members) {
if (member is! FieldDeclaration) continue;
if (member.isStatic || member.externalKeyword != null) continue;
if (member.fields.variables.length != 1) continue;
final variable = member.fields.variables.single;
final type = member.fields.type?.toSource();
if (type == null) continue;
final fieldPrefix = source.substring(member.offset, variable.offset);
if (fieldPrefix.contains(RegExp(r'\blate\b')) || fieldPrefix.contains(RegExp(r'\bexternal\b'))) continue;
final keyword = member.fields.keyword?.lexeme;
final commentStart =
member.documentationComment?.offset ?? _leadingCommentStart(source, member.offset) ?? member.offset;
final rawComment = source.substring(commentStart, member.offset).trimRight();
fields[variable.name.lexeme] = FieldInfo(
name: variable.name.lexeme,
type: type,
declaration: member,
variable: variable,
isFinal: keyword == 'final',
comment: rawComment.isEmpty ? null : _dedentComment(rawComment),
removeStart: commentStart,
);
}
return fields;
}
String? _transformedParameterList(
String source,
ConstructorDeclaration constructor,
Map<String, FieldInfo> fields,
Set<String> declaringFieldNames,
Map<String, String> privateParamToField,
) {
final params = constructor.parameters;
var text = source.substring(params.offset, params.end);
final localEdits = <Edit>[];
for (final parameter in params.parameters) {
if (parameter.metadata.isNotEmpty || parameter.covariantKeyword != null) return null;
final replacement = _transformParameter(
source,
parameter,
fields,
declaringFieldNames,
privateParamToField,
);
if (replacement == null) return null;
localEdits.add(Edit(parameter.offset - params.offset, parameter.end - parameter.offset, replacement));
}
return _applyEdits(text, localEdits);
}
String? _transformParameter(
String source,
FormalParameter parameter,
Map<String, FieldInfo> fields,
Set<String> declaringFieldNames,
Map<String, String> privateParamToField,
) {
final inner = parameter is DefaultFormalParameter ? parameter.parameter : parameter;
if (inner is FieldFormalParameter) {
if (inner.keyword != null || inner.parameters != null || inner.typeParameters != null) return null;
final field = fields[inner.name.lexeme];
if (field == null || field.variable.initializer != null) return null;
declaringFieldNames.add(field.name);
final suffix = source.substring(inner.end, parameter.end);
return _withParameterComment(
source,
parameter,
field.comment,
'${_requiredPrefix(parameter)}${field.declaringKeyword} ${field.type} ${field.name}$suffix',
);
}
final parameterName = parameter.name?.lexeme;
if (parameterName != null && privateParamToField.containsKey(parameterName)) {
final field = fields[privateParamToField[parameterName]!];
if (field == null || field.variable.initializer != null) return null;
final suffix = source.substring(inner.end, parameter.end);
return _withParameterComment(
source,
parameter,
field.comment,
'${_requiredPrefix(parameter)}${field.declaringKeyword} ${field.type} ${field.name}$suffix',
);
}
return source.substring(parameter.offset, parameter.end);
}
String _requiredPrefix(FormalParameter parameter) {
return parameter.isRequiredNamed ? 'required ' : '';
}
String _withParameterComment(String source, FormalParameter parameter, String? comment, String replacement) {
if (comment == null || comment.isEmpty) return replacement;
final indent = _lineIndent(source, parameter.offset);
final lines = comment.split('\n');
final indentedComment = lines.join('\n$indent');
return '$indentedComment\n$indent$replacement';
}
String _classHeader(String source, ClassDeclaration node, bool isConst, String params) {
final insertPoint = (node.typeParameters ?? node.name).end;
return '${source.substring(node.offset, node.classKeyword.end)}${isConst ? ' const' : ''}'
'${source.substring(node.classKeyword.end, insertPoint)}$params${source.substring(insertPoint, node.leftBracket.offset)}';
}
String _enumHeader(String source, EnumDeclaration node, String params) {
final insertPoint = (node.typeParameters ?? node.name).end;
return '${source.substring(node.offset, insertPoint)}$params${source.substring(insertPoint, node.leftBracket.offset)}';
}
String? _primaryThisInitializer(
String source,
List<ConstructorInitializer> retainedInitializers,
FunctionBody body,
) {
if (body is! EmptyFunctionBody) return null;
if (retainedInitializers.isEmpty) return null;
return 'this : ${retainedInitializers.map((i) => i.toSource()).join(', ')};';
}
bool _containsUnsupportedInitializer(ConstructorDeclaration constructor) {
for (final initializer in constructor.initializers) {
if (initializer is ConstructorFieldInitializer) continue;
if (initializer is AssertInitializer) continue;
if (initializer is SuperConstructorInvocation && initializer.constructorName == null) continue;
return true;
}
return false;
}
bool _containsNamedSuperInitializer(ConstructorDeclaration constructor) {
return constructor.initializers.any((i) => i is SuperConstructorInvocation && i.constructorName != null);
}
bool _isRedirectingGenerative(ConstructorDeclaration constructor) {
return constructor.initializers.any((i) => i is RedirectingConstructorInvocation);
}
bool _hasParameterMetadata(ConstructorDeclaration constructor) {
return constructor.parameters.parameters.any((p) => p.metadata.isNotEmpty);
}
bool _canUseConstructorShorthand(ConstructorDeclaration constructor) {
return constructor.factoryKeyword == null &&
constructor.externalKeyword == null &&
constructor.metadata.isEmpty &&
!_hasParameterMetadata(constructor);
}
void _applyConstructorShorthand(ConstructorDeclaration constructor, List<Edit> edits) {
final start = constructor.returnType.offset;
final end = constructor.name?.end ?? constructor.returnType.end;
final replacement = constructor.name == null ? 'new' : 'new ${constructor.name!.lexeme}';
edits.add(Edit(start, end - start, replacement));
}
String _constructorDisplayName(String className, ConstructorDeclaration constructor) {
return constructor.name == null ? '$className()' : '$className.${constructor.name!.lexeme}';
}
Edit _removeWholeMember(String source, int start, int end) {
final removeStart = _lineStart(source, start);
final removeEnd = _lineEndIncludingNewline(source, end);
return Edit(removeStart, removeEnd - removeStart, '');
}
int? _leadingCommentStart(String source, int declarationOffset) {
final currentLineStart = _lineStart(source, declarationOffset);
if (currentLineStart == 0) return null;
final previousLineEnd = currentLineStart - 1;
final previousLineStart = _lineStart(source, previousLineEnd);
final previousLine = source.substring(previousLineStart, previousLineEnd).trimRight();
final trimmed = previousLine.trimLeft();
if (trimmed.startsWith('///') || trimmed.startsWith('//')) {
var start = previousLineStart;
while (start > 0) {
final beforeEnd = start - 1;
final beforeStart = _lineStart(source, beforeEnd);
final before = source.substring(beforeStart, beforeEnd).trimRight().trimLeft();
if (!before.startsWith('///') && !before.startsWith('//')) break;
start = beforeStart;
}
return start;
}
if (trimmed.endsWith('*/')) {
var start = previousLineStart;
while (start > 0) {
final line = source.substring(start, _lineEndExcludingNewline(source, start)).trimLeft();
if (line.startsWith('/*')) return start;
start = _lineStart(source, start - 1);
}
}
return null;
}
String _dedentComment(String comment) {
final lines = comment.split('\n');
final indents = lines
.where((line) => line.trim().isNotEmpty)
.map((line) => RegExp(r'^\s*').firstMatch(line)!.group(0)!.length)
.toList();
if (indents.isEmpty) return comment;
final minIndent = indents.reduce((a, b) => a < b ? a : b);
return lines.map((line) => line.length >= minIndent ? line.substring(minIndent) : line).join('\n');
}
int _lineStart(String source, int offset) {
final index = source.lastIndexOf('\n', offset - 1);
return index == -1 ? 0 : index + 1;
}
int _lineEndIncludingNewline(String source, int offset) {
final index = source.indexOf('\n', offset);
return index == -1 ? source.length : index + 1;
}
int _lineEndExcludingNewline(String source, int offset) {
final index = source.indexOf('\n', offset);
return index == -1 ? source.length : index;
}
String _lineIndent(String source, int offset) {
final start = _lineStart(source, offset);
final line = source.substring(start, offset);
return RegExp(r'^\s*').firstMatch(line)!.group(0)!;
}
String _applyEdits(String source, List<Edit> edits) {
edits.sort((a, b) => b.offset.compareTo(a.offset));
var result = source;
for (final edit in edits) {
result = result.replaceRange(edit.offset, edit.offset + edit.length, edit.replacement);
}
return result;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment