update_icons.dart 19.4 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
// Regenerates a Dart file with a class containing IconData constants.
6
// See https://github.com/flutter/flutter/wiki/Updating-Material-Design-Fonts-&-Icons
7 8
// Should be idempotent with:
// dart dev/tools/update_icons.dart --new-codepoints bin/cache/artifacts/material_fonts/codepoints
9

10
import 'dart:collection';
11 12 13 14
import 'dart:convert' show LineSplitter;
import 'dart:io';

import 'package:args/args.dart';
15
import 'package:meta/meta.dart';
16 17
import 'package:path/path.dart' as path;

18 19
const String _iconsPathOption = 'icons';
const String _iconsTemplatePathOption = 'icons-template';
20 21
const String _newCodepointsPathOption = 'new-codepoints';
const String _oldCodepointsPathOption = 'old-codepoints';
22
const String _fontFamilyOption = 'font-family';
23
const String _possibleStyleSuffixesOption = 'style-suffixes';
24
const String _classNameOption = 'class-name';
25
const String _enforceSafetyChecks = 'enforce-safety-checks';
26
const String _dryRunOption = 'dry-run';
27

28
const String _defaultIconsPath = 'packages/flutter/lib/src/material/icons.dart';
29 30
const String _defaultNewCodepointsPath = 'codepoints';
const String _defaultOldCodepointsPath = 'bin/cache/artifacts/material_fonts/codepoints';
31
const String _defaultFontFamily = 'MaterialIcons';
32 33 34 35 36
const List<String> _defaultPossibleStyleSuffixes = <String>[
  '_outlined',
  '_rounded',
  '_sharp',
];
37
const String _defaultClassName = 'Icons';
38
const String _defaultDemoFilePath = '/tmp/new_icons_demo.dart';
39

Pierre-Louis's avatar
Pierre-Louis committed
40 41 42 43 44 45 46 47 48 49 50 51 52 53
const String _beginGeneratedMark = '// BEGIN GENERATED ICONS';
const String _endGeneratedMark = '// END GENERATED ICONS';
const String _beginPlatformAdaptiveGeneratedMark = '// BEGIN GENERATED PLATFORM ADAPTIVE ICONS';
const String _endPlatformAdaptiveGeneratedMark = '// END GENERATED PLATFORM ADAPTIVE ICONS';

const Map<String, List<String>> _platformAdaptiveIdentifiers = <String, List<String>>{
  // Mapping of Flutter IDs to an Android/agnostic ID and an iOS ID.
  // Flutter IDs can be anything, but should be chosen to be agnostic.
  'arrow_back': <String>['arrow_back', 'arrow_back_ios'],
  'arrow_forward': <String>['arrow_forward', 'arrow_forward_ios'],
  'flip_camera': <String>['flip_camera_android', 'flip_camera_ios'],
  'more': <String>['more_vert', 'more_horiz'],
  'share': <String>['share', 'ios_share'],
};
54

55
// Rewrite certain Flutter IDs (numbers) using prefix matching.
56
const Map<String, String> _identifierPrefixRewrites = <String, String>{
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
  '1': 'one_',
  '2': 'two_',
  '3': 'three_',
  '4': 'four_',
  '5': 'five_',
  '6': 'six_',
  '7': 'seven_',
  '8': 'eight_',
  '9': 'nine_',
  '10': 'ten_',
  '11': 'eleven_',
  '12': 'twelve_',
  '13': 'thirteen_',
  '14': 'fourteen_',
  '15': 'fifteen_',
  '16': 'sixteen_',
  '17': 'seventeen_',
  '18': 'eighteen_',
  '19': 'nineteen_',
  '20': 'twenty_',
  '21': 'twenty_one_',
  '22': 'twenty_two_',
  '23': 'twenty_three_',
  '24': 'twenty_four_',
  '30': 'thirty_',
  '60': 'sixty_',
  '123': 'onetwothree',
  '360': 'threesixty',
  '2d': 'twod',
  '3d': 'threed',
  '3d_rotation': 'threed_rotation',
88 89 90
};

// Rewrite certain Flutter IDs (reserved keywords) using exact matching.
91
const Map<String, String> _identifierExactRewrites = <String, String>{
92
  'class': 'class_',
93 94
  'new': 'new_',
  'switch': 'switch_',
95
  'try': 'try_sms_star',
Pierre-Louis's avatar
Pierre-Louis committed
96 97
  'door_back': 'door_back_door',
  'door_front': 'door_front_door',
98 99
};

100
const Set<String> _iconsMirroredWhenRTL = <String>{
101
  // This list is obtained from:
102
  // https://developers.google.com/fonts/docs/material_icons#which_icons_should_be_mirrored_for_rtl
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152
  'arrow_back',
  'arrow_back_ios',
  'arrow_forward',
  'arrow_forward_ios',
  'arrow_left',
  'arrow_right',
  'assignment',
  'assignment_return',
  'backspace',
  'battery_unknown',
  'call_made',
  'call_merge',
  'call_missed',
  'call_missed_outgoing',
  'call_received',
  'call_split',
  'chevron_left',
  'chevron_right',
  'chrome_reader_mode',
  'device_unknown',
  'dvr',
  'event_note',
  'featured_play_list',
  'featured_video',
  'first_page',
  'flight_land',
  'flight_takeoff',
  'format_indent_decrease',
  'format_indent_increase',
  'format_list_bulleted',
  'forward',
  'functions',
  'help',
  'help_outline',
  'input',
  'keyboard_backspace',
  'keyboard_tab',
  'label',
  'label_important',
  'label_outline',
  'last_page',
  'launch',
  'list',
  'live_help',
  'mobile_screen_share',
  'multiline_chart',
  'navigate_before',
  'navigate_next',
  'next_week',
  'note',
153
  'open_in',
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
  'playlist_add',
  'queue_music',
  'redo',
  'reply',
  'reply_all',
  'screen_share',
  'send',
  'short_text',
  'show_chart',
  'sort',
  'star_half',
  'subject',
  'trending_flat',
  'toc',
  'trending_down',
  'trending_up',
  'undo',
  'view_list',
  'view_quilt',
  'wrap_text',
174
};
175

176 177
void main(List<String> args) {
  // If we're run from the `tools` dir, set the cwd to the repo root.
178
  if (path.basename(Directory.current.path) == 'tools') {
179
    Directory.current = Directory.current.parent.parent;
180
  }
181

182
  final ArgResults argResults = _handleArguments(args);
183

184 185 186 187 188 189 190 191
  final File iconsFile = File(path.normalize(path.absolute(argResults[_iconsPathOption] as String)));
  if (!iconsFile.existsSync()) {
    stderr.writeln('Error: Icons file not found: ${iconsFile.path}');
    exit(1);
  }
  final File iconsTemplateFile = File(path.normalize(path.absolute(argResults[_iconsTemplatePathOption] as String)));
  if (!iconsTemplateFile.existsSync()) {
    stderr.writeln('Error: Icons template file not found: ${iconsTemplateFile.path}');
192 193
    exit(1);
  }
194
  final File newCodepointsFile = File(argResults[_newCodepointsPathOption] as String);
195 196
  if (!newCodepointsFile.existsSync()) {
    stderr.writeln('Error: New codepoints file not found: ${newCodepointsFile.path}');
197 198
    exit(1);
  }
199
  final File oldCodepointsFile = File(argResults[_oldCodepointsPathOption] as String);
200 201
  if (!oldCodepointsFile.existsSync()) {
    stderr.writeln('Error: Old codepoints file not found: ${oldCodepointsFile.path}');
202 203 204
    exit(1);
  }

205
  final String newCodepointsString = newCodepointsFile.readAsStringSync();
206
  final Map<String, String> newTokenPairMap = stringToTokenPairMap(newCodepointsString);
207 208

  final String oldCodepointsString = oldCodepointsFile.readAsStringSync();
209
  final Map<String, String> oldTokenPairMap = stringToTokenPairMap(oldCodepointsString);
210

211 212 213 214 215 216 217
  stderr.writeln('Performing safety checks');
  final bool isSuperset = testIsSuperset(newTokenPairMap, oldTokenPairMap);
  final bool isStable = testIsStable(newTokenPairMap, oldTokenPairMap);
  if ((!isSuperset || !isStable) && argResults[_enforceSafetyChecks] as bool) {
    exit(1);
  }
  final String iconsTemplateContents = iconsTemplateFile.readAsStringSync();
218

219 220 221 222 223
  stderr.writeln("Generating icons ${argResults[_dryRunOption] as bool ? '' : 'to ${iconsFile.path}'}");
  final String newIconsContents = _regenerateIconsFile(
    iconsTemplateContents,
    newTokenPairMap,
    argResults[_fontFamilyOption] as String,
224
    argResults[_classNameOption] as String,
225 226
    argResults[_enforceSafetyChecks] as bool,
  );
227 228

  if (argResults[_dryRunOption] as bool) {
229
    stdout.write(newIconsContents);
230
  } else {
231
    iconsFile.writeAsStringSync(newIconsContents);
232 233 234 235 236 237

    final SplayTreeMap<String, String> sortedNewTokenPairMap = SplayTreeMap<String, String>.of(newTokenPairMap);
    _regenerateCodepointsFile(oldCodepointsFile, sortedNewTokenPairMap);

    sortedNewTokenPairMap.removeWhere((String key, String value) => oldTokenPairMap.containsKey(key));
    _generateIconDemo(File(_defaultDemoFilePath), sortedNewTokenPairMap);
238
  }
239 240
}

241 242
ArgResults _handleArguments(List<String> args) {
  final ArgParser argParser = ArgParser()
243 244 245 246 247 248 249
    ..addOption(_iconsPathOption,
        defaultsTo: _defaultIconsPath,
        help: 'Location of the material icons file')
    ..addOption(_iconsTemplatePathOption,
        defaultsTo: _defaultIconsPath,
        help:
            'Location of the material icons file template. Usually the same as --$_iconsPathOption')
250 251 252 253 254 255
    ..addOption(_newCodepointsPathOption,
        defaultsTo: _defaultNewCodepointsPath,
        help: 'Location of the new codepoints directory')
    ..addOption(_oldCodepointsPathOption,
        defaultsTo: _defaultOldCodepointsPath,
        help: 'Location of the existing codepoints directory')
256 257 258
    ..addOption(_fontFamilyOption,
        defaultsTo: _defaultFontFamily,
        help: 'The font family to use for the IconData constants')
259 260 261 262
    ..addMultiOption(_possibleStyleSuffixesOption,
        defaultsTo: _defaultPossibleStyleSuffixes,
        help: 'A comma-separated list of suffixes (typically an optional '
              'family + a style) e.g. _outlined, _monoline_filled')
263 264 265
    ..addOption(_classNameOption,
        defaultsTo: _defaultClassName,
        help: 'The containing class for all icons')
266 267 268
    ..addFlag(_enforceSafetyChecks,
        defaultsTo: true,
        help: 'Whether to exit if safety checks fail (e.g. codepoints are missing or unstable')
269
    ..addFlag(_dryRunOption);
270 271 272 273 274 275
  argParser.addFlag('help', abbr: 'h', negatable: false, callback: (bool help) {
    if (help) {
      print(argParser.usage);
      exit(1);
    }
  });
276 277 278
  return argParser.parse(args);
}

279
Map<String, String> stringToTokenPairMap(String codepointData) {
280 281 282 283
  final Iterable<String> cleanData = LineSplitter.split(codepointData)
      .map((String line) => line.trim())
      .where((String line) => line.isNotEmpty);

284
  final Map<String, String> pairs = <String, String>{};
285 286 287 288 289 290 291 292 293 294 295 296

  for (final String line in cleanData) {
    final List<String> tokens = line.split(' ');
    if (tokens.length != 2) {
      throw FormatException('Unexpected codepoint data: $line');
    }
    pairs.putIfAbsent(tokens[0], () => tokens[1]);
  }

  return pairs;
}

297 298 299 300
String _regenerateIconsFile(
    String templateFileContents,
    Map<String, String> tokenPairMap,
    String fontFamily,
301
    String className,
302 303
    bool enforceSafetyChecks,
  ) {
304
  final List<Icon> newIcons = tokenPairMap.entries
305 306
      .map((MapEntry<String, String> entry) =>
        Icon(entry, fontFamily: fontFamily, className: className))
307
      .toList();
308
  newIcons.sort((Icon a, Icon b) => a._compareTo(b));
309

310
  final StringBuffer buf = StringBuffer();
311
  bool generating = false;
Pierre-Louis's avatar
Pierre-Louis committed
312

313
  for (final String line in LineSplitter.split(templateFileContents)) {
314
    if (!generating) {
315
      buf.writeln(line);
316
    }
Pierre-Louis's avatar
Pierre-Louis committed
317

318
    // Generate for PlatformAdaptiveIcons
Pierre-Louis's avatar
Pierre-Louis committed
319
    if (line.contains(_beginPlatformAdaptiveGeneratedMark)) {
320
      generating = true;
Pierre-Louis's avatar
Pierre-Louis committed
321 322
      final List<String> platformAdaptiveDeclarations = <String>[];
      _platformAdaptiveIdentifiers.forEach((String flutterId, List<String> ids) {
323
        // Automatically finds and generates all icon declarations.
324
        for (final String style in <String>['', '_outlined', '_rounded', '_sharp']) {
Pierre-Louis's avatar
Pierre-Louis committed
325
          try {
326 327
            final Icon agnosticIcon = newIcons.firstWhere(
                (Icon icon) => icon.id == '${ids[0]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
328
                orElse: () => throw ids[0]);
329 330
            final Icon iOSIcon = newIcons.firstWhere(
                (Icon icon) => icon.id == '${ids[1]}$style',
Pierre-Louis's avatar
Pierre-Louis committed
331
                orElse: () => throw ids[1]);
332 333
            platformAdaptiveDeclarations.add(
              agnosticIcon.platformAdaptiveDeclaration('$flutterId$style', iOSIcon),
334
            );
Pierre-Louis's avatar
Pierre-Louis committed
335
          } catch (e) {
336
            if (style == '') {
337 338 339 340 341 342
              // Throw an error for baseline icons.
              stderr.writeln("❌ Platform adaptive icon '$e' not found.");
              if (enforceSafetyChecks) {
                stderr.writeln('Safety checks failed');
                exit(1);
              }
Pierre-Louis's avatar
Pierre-Louis committed
343 344 345 346 347 348 349 350 351 352 353
            } else {
              // Ignore errors for styled icons since some don't exist.
            }
          }
        }
      });
      buf.write(platformAdaptiveDeclarations.join());
    } else if (line.contains(_endPlatformAdaptiveGeneratedMark)) {
      generating = false;
      buf.writeln(line);
    }
354

Pierre-Louis's avatar
Pierre-Louis committed
355 356 357
    // Generate for Icons
    if (line.contains(_beginGeneratedMark)) {
      generating = true;
358
      final String iconDeclarationsString = newIcons.map((Icon icon) => icon.fullDeclaration).join();
359 360
      buf.write(iconDeclarationsString);
    } else if (line.contains(_endGeneratedMark)) {
361 362 363 364 365 366 367
      generating = false;
      buf.writeln(line);
    }
  }
  return buf.toString();
}

368 369
@visibleForTesting
bool testIsSuperset(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
370 371 372
  final Set<String> newCodepointsSet = newCodepoints.keys.toSet();
  final Set<String> oldCodepointsSet = oldCodepoints.keys.toSet();

373 374 375 376
  final int diff = newCodepointsSet.length - oldCodepointsSet.length;
  if (diff > 0) {
    stderr.writeln('🆕 $diff new codepoints: ${newCodepointsSet.difference(oldCodepointsSet)}');
  }
377
  if (!newCodepointsSet.containsAll(oldCodepointsSet)) {
378 379 380 381
    stderr.writeln(
        '❌ new codepoints file does not contain all ${oldCodepointsSet.length} '
        'existing codepoints. Missing: ${oldCodepointsSet.difference(newCodepointsSet)}');
    return false;
382
  } else {
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397
    stderr.writeln('✅ new codepoints file contains all ${oldCodepointsSet.length} existing codepoints');
  }
  return true;
}

@visibleForTesting
bool testIsStable(Map<String, String> newCodepoints, Map<String, String> oldCodepoints) {
  final int oldCodepointsCount = oldCodepoints.length;
  final List<String> unstable = <String>[];

  oldCodepoints.forEach((String key, String value) {
    if (newCodepoints.containsKey(key)) {
      if (value != newCodepoints[key]) {
        unstable.add(key);
      }
398
    }
399 400 401 402 403 404 405 406
  });

  if (unstable.isNotEmpty) {
    stderr.writeln('❌ out of $oldCodepointsCount existing codepoints, ${unstable.length} were unstable: $unstable');
    return false;
  } else {
    stderr.writeln('✅ all existing $oldCodepointsCount codepoints are stable');
    return true;
407
  }
408
}
Pierre-Louis's avatar
Pierre-Louis committed
409

410
void _regenerateCodepointsFile(File oldCodepointsFile, Map<String, String> tokenPairMap) {
411
  stderr.writeln('Regenerating old codepoints file ${oldCodepointsFile.path}');
412 413

  final StringBuffer buf = StringBuffer();
414
  tokenPairMap.forEach((String key, String value) => buf.writeln('$key $value'));
415
  oldCodepointsFile.writeAsStringSync(buf.toString());
416 417
}

418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443
void _generateIconDemo(File demoFilePath, Map<String, String> tokenPairMap) {
  if (tokenPairMap.isEmpty) {
    stderr.writeln('No new icons, skipping generating icon demo');
    return;
  }
  stderr.writeln('Generating icon demo at $_defaultDemoFilePath');

  final StringBuffer newIconUsages = StringBuffer();
  for (final MapEntry<String, String> entry in tokenPairMap.entries) {
    newIconUsages.writeln(Icon(entry).usage);
  }
  final String demoFileContents = '''
    import 'package:flutter/material.dart';

    void main() => runApp(const IconDemo());

    class IconDemo extends StatelessWidget {
      const IconDemo({ Key? key }) : super(key: key);

      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: Wrap(
              children: const [
444
                $newIconUsages
445 446 447 448 449 450 451 452 453 454
              ],
            ),
          ),
        );
      }
    }
    ''';
  demoFilePath.writeAsStringSync(demoFileContents);
}

455
class Icon {
456
  // Parse tokenPair (e.g. {"6_ft_apart_outlined": "e004"}).
457 458
  Icon(MapEntry<String, String> tokenPair, {
    this.fontFamily = _defaultFontFamily,
459
    this.possibleStyleSuffixes = _defaultPossibleStyleSuffixes,
460 461
    this.className = _defaultClassName,
  }) {
462 463 464
    id = tokenPair.key;
    hexCodepoint = tokenPair.value;

465
    // Determine family and HTML class suffix for Dartdoc.
466
    if (id.endsWith('_gm_outlined')) {
467 468
      dartdocFamily = 'GM';
      dartdocHtmlSuffix = '-outlined';
469
    } else if (id.endsWith('_gm_filled')) {
470 471
      dartdocFamily = 'GM';
      dartdocHtmlSuffix = '-filled';
472
    } else if (id.endsWith('_monoline_outlined')) {
473 474
      dartdocFamily = 'Monoline';
      dartdocHtmlSuffix = '-outlined';
475
    } else if (id.endsWith('_monoline_filled')) {
476 477
      dartdocFamily = 'Monoline';
      dartdocHtmlSuffix = '-filled';
478
    } else {
479
      dartdocFamily = 'material';
480 481
      if (id.endsWith('_baseline')) {
        id = _removeLast(id, '_baseline');
482
        dartdocHtmlSuffix = '';
483
      } else if (id.endsWith('_outlined')) {
484
        dartdocHtmlSuffix = '-outlined';
485
      } else if (id.endsWith('_rounded')) {
486
        dartdocHtmlSuffix = '-round';
487
      } else if (id.endsWith('_sharp')) {
488
        dartdocHtmlSuffix = '-sharp';
489 490
      }
    }
491

492 493
    _generateShortId();
    _generateFlutterId();
494 495 496
  }


497 498 499 500
  late String id; // e.g. 5g, 5g_outlined, 5g_rounded, 5g_sharp
  late String shortId; // e.g. 5g
  late String flutterId; // e.g. five_g, five_g_outlined, five_g_rounded, five_g_sharp
  late String hexCodepoint; // e.g. e547
501 502
  late String dartdocFamily; // e.g. material
  late String dartdocHtmlSuffix = ''; // The suffix for the 'material-icons' HTML class.
503
  String fontFamily; // The IconData font family.
504
  List<String> possibleStyleSuffixes; // A list of possible suffixes e.g. _outlined, _monoline_filled.
505
  String className; // The containing class.
506

507
  String get name => shortId.replaceAll('_', ' ').trim();
508

509
  String get style => dartdocHtmlSuffix == '' ? '' : ' (${dartdocHtmlSuffix.replaceFirst('-', '')})';
510

511
  String get dartDoc =>
512
      '<i class="material-icons$dartdocHtmlSuffix md-36">$shortId</i> &#x2014; $dartdocFamily icon named "$name"$style';
513

514
  String get usage => 'Icon($className.$flutterId),';
515

516 517 518 519 520
  bool get isMirroredInRTL {
    // Remove common suffixes (e.g. "_new" or "_alt") from the shortId.
    final String normalizedShortId = shortId.replaceAll(RegExp(r'_(new|alt|off|on)$'), '');
    return _iconsMirroredWhenRTL.any((String shortIdMirroredWhenRTL) => normalizedShortId == shortIdMirroredWhenRTL);
  }
521 522

  String get declaration =>
523
      "static const IconData $flutterId = IconData(0x$hexCodepoint, fontFamily: '$fontFamily'${isMirroredInRTL ? ', matchTextDirection: true' : ''});";
524

Pierre-Louis's avatar
Pierre-Louis committed
525 526 527 528 529 530
  String get fullDeclaration => '''

  /// $dartDoc.
  $declaration
''';

531
  String platformAdaptiveDeclaration(String fullFlutterId, Icon iOSIcon) => '''
Pierre-Louis's avatar
Pierre-Louis committed
532

533 534
  /// Platform-adaptive icon for $dartDoc and ${iOSIcon.dartDoc}.;
  IconData get $fullFlutterId => !_isCupertino() ? $className.$flutterId : $className.${iOSIcon.flutterId};
Pierre-Louis's avatar
Pierre-Louis committed
535 536 537 538
''';

  @override
  String toString() => id;
539

540
  /// Analogous to [String.compareTo]
541
  int _compareTo(Icon b) {
542
    if (shortId == b.shortId) {
543
      // Sort a regular icon before its variants.
544 545
      return id.length - b.id.length;
    }
546
    return shortId.compareTo(b.shortId);
547 548
  }

549
  static String _removeLast(String string, String toReplace) {
550 551
    return string.replaceAll(RegExp('$toReplace\$'), '');
  }
552

553 554 555 556
  /// See [shortId].
  void _generateShortId() {
    shortId = id;
    for (final String styleSuffix in possibleStyleSuffixes) {
557
      shortId = _removeLast(shortId, styleSuffix);
558 559 560 561 562 563
      if (shortId != id) {
        break;
      }
    }
  }

564 565 566
  /// See [flutterId].
  void _generateFlutterId() {
    flutterId = id;
567
    // Exact identifier rewrites.
568
    for (final MapEntry<String, String> rewritePair in _identifierExactRewrites.entries) {
569
      if (shortId == rewritePair.key) {
570 571
        flutterId = id.replaceFirst(
          rewritePair.key,
572
          _identifierExactRewrites[rewritePair.key]!,
573
        );
574 575
      }
    }
576
    // Prefix identifier rewrites.
577
    for (final MapEntry<String, String> rewritePair in _identifierPrefixRewrites.entries) {
578
      if (id.startsWith(rewritePair.key)) {
579 580
        flutterId = id.replaceFirst(
          rewritePair.key,
581
          _identifierPrefixRewrites[rewritePair.key]!,
582
        );
583 584
      }
    }
585 586 587

    // Prevent double underscores.
    flutterId = flutterId.replaceAll('__', '_');
588
  }
589
}