Created
June 10, 2026 16:44
-
-
Save bizz84/f31ab7ed9568f5df163759c290113666 to your computer and use it in GitHub Desktop.
Prototype CLI for Dart primary constructor migration (built by GPT-5.5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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