Fix out of sync templates files and add a check (#145747)

### Description
- Add a check to verify template code in the Material library is synced with `gen_defaults`
- Sync the changes to pass the new check.
This commit is contained in:
Taha Tesser 2024-04-11 16:40:36 +03:00 committed by GitHub
parent ac5be2d821
commit 9436b3c1c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 538 additions and 13 deletions

View File

@ -12,6 +12,7 @@ import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:collection/equality.dart';
import 'package:crypto/crypto.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
@ -252,6 +253,10 @@ Future<void> run(List<String> arguments) async {
printProgress('Correct file names in gen_defaults.dart...');
await verifyTokenTemplatesUpdateCorrectFiles(flutterRoot);
// Ensure material library files are up-to-date with the token template files.
printProgress('Material library files are up-to-date with token template files...');
await verifyMaterialFilesAreUpToDateWithTemplateFiles(flutterRoot, dart);
// Ensure integration test files are up-to-date with the app template.
printProgress('Up to date integration test template files...');
await verifyIntegrationTestTemplateFiles(flutterRoot);
@ -399,6 +404,78 @@ Future<void> verifyTokenTemplatesUpdateCorrectFiles(String workingDirectory) asy
}
}
/// Verify Material library files are up-to-date with the token template files
/// when running /dev/tools/gen_defaults/bin/gen_defaults.dart.
Future<void> verifyMaterialFilesAreUpToDateWithTemplateFiles(String workingDirectory, String dartExecutable) async {
final List<String> errors = <String>[];
const String beginGeneratedComment = '// BEGIN GENERATED TOKEN PROPERTIES';
String getMaterialDirPath(List<String> lines) {
final String line = lines.firstWhere((String line) => line.contains('String materialLib'));
final String relativePath = line.substring(line.indexOf("'") + 1, line.lastIndexOf("'"));
return path.join(workingDirectory, relativePath);
}
String getFileName(String line) {
const String materialLibString = r"'$materialLib/";
final String leftClamp = line.substring(line.indexOf(materialLibString) + materialLibString.length);
return leftClamp.substring(0, leftClamp.indexOf("'"));
}
// Get the template generated code from the file.
List<String> getGeneratedCode(List<String> lines) {
return lines.skipWhile((String line) => !line.contains(beginGeneratedComment)).toList();
}
final String genDefaultsBinDir = '$workingDirectory/dev/tools/gen_defaults/bin';
final File file = File(path.join(genDefaultsBinDir, 'gen_defaults.dart'));
final List<String> lines = file.readAsLinesSync();
final String materialDirPath = getMaterialDirPath(lines);
final Map<String, List<String>> beforeGeneratedCode = <String, List<String>>{};
final Map<String, List<String>> afterGeneratedCode = <String, List<String>>{};
for (final String line in lines) {
if (line.contains('updateFile();')) {
final String fileName = getFileName(line);
final String filePath = path.join(materialDirPath, fileName);
final File file = File(filePath);
beforeGeneratedCode[fileName] = getGeneratedCode(file.readAsLinesSync());
}
}
// Run gen_defaults.dart to generate the token template files.
await runCommand(dartExecutable,
<String>['--enable-asserts', path.join('dev', 'tools', 'gen_defaults', 'bin', 'gen_defaults.dart')],
workingDirectory: workingDirectory,
);
for (final String line in lines) {
if (line.contains('updateFile();')) {
final String fileName = getFileName(line);
final String filePath = path.join(materialDirPath, fileName);
final File file = File(filePath);
afterGeneratedCode[fileName] = getGeneratedCode(file.readAsLinesSync());
}
}
// Compare the generated code before and after running gen_defaults.dart.
for (final String fileName in beforeGeneratedCode.keys) {
final List<String> before = beforeGeneratedCode[fileName]!;
final List<String> after = afterGeneratedCode[fileName]!;
if (!const IterableEquality<String>().equals(before, after)) {
errors.add('$fileName is not up-to-date with the token template file.');
}
}
// Fail if any errors.
if (errors.isNotEmpty) {
foundError(<String>[
...errors,
'${bold}See: https://github.com/flutter/flutter/blob/master/dev/tools/gen_defaults to update the token template files.$reset',
]);
}
}
/// Verify tool test files end in `_test.dart`.
///
/// The test runner will only recognize files ending in `_test.dart` as tests to

View File

@ -0,0 +1,16 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'template.dart';
class ChipTemplate extends TokenTemplate {
const ChipTemplate(super.blockName, super.fileName, super.tokens);
@override
String generate() => '''
class _${blockName}DefaultsM3 {
final String testString = 'This is chip_template.dart class.';
}
''';
}

View File

@ -0,0 +1,12 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chip_template.dart';
const String materialLib = 'packages/flutter/lib/src/material';
Future<void> main(List<String> args) async {
final Map<String, dynamic> tokens = <String, dynamic>{};
ChipTemplate('Chip', '$materialLib/chip.dart', tokens).updateFile();
}

View File

@ -0,0 +1,279 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
import 'token_logger.dart';
/// Base class for code generation templates.
abstract class TokenTemplate {
const TokenTemplate(this.blockName, this.fileName, this._tokens, {
this.colorSchemePrefix = 'Theme.of(context).colorScheme.',
this.textThemePrefix = 'Theme.of(context).textTheme.'
});
/// Name of the code block that this template will generate.
///
/// Used to identify an existing block when updating it.
final String blockName;
/// Name of the file that will be updated with the generated code.
final String fileName;
/// Map of token data extracted from the Material Design token database.
final Map<String, dynamic> _tokens;
/// Optional prefix prepended to color definitions.
///
/// Defaults to 'Theme.of(context).colorScheme.'
final String colorSchemePrefix;
/// Optional prefix prepended to text style definitions.
///
/// Defaults to 'Theme.of(context).textTheme.'
final String textThemePrefix;
/// Check if a token is available.
bool tokenAvailable(String tokenName) => _tokens.containsKey(tokenName);
/// Resolve a token while logging its usage.
/// There will be no log if [optional] is true and the token doesn't exist.
dynamic getToken(String tokenName, {bool optional = false}) {
if (optional && !tokenAvailable(tokenName)) {
return null;
}
tokenLogger.log(tokenName);
return _tokens[tokenName];
}
static const String beginGeneratedComment = '''
// BEGIN GENERATED TOKEN PROPERTIES''';
static const String headerComment = '''
// Do not edit by hand. The code between the "BEGIN GENERATED" and
// "END GENERATED" comments are generated from data in the Material
// Design token database by the script:
// dev/tools/gen_defaults/bin/gen_defaults.dart.
''';
static const String endGeneratedComment = '''
// END GENERATED TOKEN PROPERTIES''';
/// Replace or append the contents of the file with the text from [generate].
///
/// If the file already contains a generated text block matching the
/// [blockName], it will be replaced by the [generate] output. Otherwise
/// the content will just be appended to the end of the file.
Future<void> updateFile() async {
final String contents = File(fileName).readAsStringSync();
final String beginComment = '$beginGeneratedComment - $blockName\n';
final String endComment = '$endGeneratedComment - $blockName\n';
final int beginPreviousBlock = contents.indexOf(beginComment);
final int endPreviousBlock = contents.indexOf(endComment);
late String contentBeforeBlock;
late String contentAfterBlock;
if (beginPreviousBlock != -1) {
if (endPreviousBlock < beginPreviousBlock) {
print('Unable to find block named $blockName in $fileName, skipping code generation.');
return;
}
// Found a valid block matching the name, so record the content before and after.
contentBeforeBlock = contents.substring(0, beginPreviousBlock);
contentAfterBlock = contents.substring(endPreviousBlock + endComment.length);
} else {
// Just append to the bottom.
contentBeforeBlock = contents;
contentAfterBlock = '';
}
final StringBuffer buffer = StringBuffer(contentBeforeBlock);
buffer.write(beginComment);
buffer.write(headerComment);
buffer.write(generate());
buffer.write(endComment);
buffer.write(contentAfterBlock);
File(fileName).writeAsStringSync(buffer.toString());
}
/// Provide the generated content for the template.
///
/// This abstract method needs to be implemented by subclasses
/// to provide the content that [updateFile] will append to the
/// bottom of the file.
String generate();
/// Generate a [ColorScheme] color name for the given token.
///
/// If there is a value for the given token, this will return
/// the value prepended with [colorSchemePrefix].
///
/// Otherwise it will return [defaultValue] if provided or 'null' if not.
///
/// If a [defaultValue] is not provided and the token doesn't exist, the token
/// lookup is logged and a warning will be shown at the end of the process.
///
/// See also:
/// * [componentColor], that provides support for an optional opacity.
String color(String colorToken, [String? defaultValue]) {
final String effectiveDefault = defaultValue ?? 'null';
final dynamic tokenVal = getToken(colorToken, optional: defaultValue != null);
return tokenVal == null ? effectiveDefault : '$colorSchemePrefix$tokenVal';
}
/// Generate a [ColorScheme] color name for the given token or a transparent
/// color if there is no value for the token.
///
/// If there is a value for the given token, this will return
/// the value prepended with [colorSchemePrefix].
///
/// Otherwise it will return 'Colors.transparent'.
///
/// See also:
/// * [componentColor], that provides support for an optional opacity.
String? colorOrTransparent(String token) => color(token, 'Colors.transparent');
/// Generate a [ColorScheme] color name for the given component's color
/// with opacity if available.
///
/// If there is a value for the given component's color, this will return
/// the value prepended with [colorSchemePrefix]. If there is also
/// an opacity specified for the component, then the returned value
/// will include this opacity calculation.
///
/// If there is no value for the component's color, 'null' will be returned.
///
/// See also:
/// * [color], that provides support for looking up a raw color token.
String componentColor(String componentToken) {
final String colorToken = '$componentToken.color';
if (!tokenAvailable(colorToken)) {
return 'null';
}
String value = color(colorToken);
final String opacityToken = '$componentToken.opacity';
if (tokenAvailable(opacityToken)) {
value += '.withOpacity(${opacity(opacityToken)})';
}
return value;
}
/// Generate the opacity value for the given token.
String? opacity(String token) {
tokenLogger.log(token);
return _numToString(getToken(token));
}
String? _numToString(Object? value, [int? digits]) {
if (value == null) {
return null;
}
if (value is num) {
if (value == double.infinity) {
return 'double.infinity';
}
return digits == null ? value.toString() : value.toStringAsFixed(digits);
}
return getToken(value as String).toString();
}
/// Generate an elevation value for the given component token.
String elevation(String componentToken) {
return getToken(getToken('$componentToken.elevation')! as String)!.toString();
}
/// Generate a size value for the given component token.
///
/// Non-square sizes are specified as width and height.
String size(String componentToken) {
final String sizeToken = '$componentToken.size';
if (!tokenAvailable(sizeToken)) {
final String widthToken = '$componentToken.width';
final String heightToken = '$componentToken.height';
if (!tokenAvailable(widthToken) && !tokenAvailable(heightToken)) {
throw Exception('Unable to find width, height, or size tokens for $componentToken');
}
final String? width = _numToString(tokenAvailable(widthToken) ? getToken(widthToken)! as num : double.infinity, 0);
final String? height = _numToString(tokenAvailable(heightToken) ? getToken(heightToken)! as num : double.infinity, 0);
return 'const Size($width, $height)';
}
return 'const Size.square(${_numToString(getToken(sizeToken))})';
}
/// Generate a shape constant for the given component token.
///
/// Currently supports family:
/// - "SHAPE_FAMILY_ROUNDED_CORNERS" which maps to [RoundedRectangleBorder].
/// - "SHAPE_FAMILY_CIRCULAR" which maps to a [StadiumBorder].
String shape(String componentToken, [String prefix = 'const ']) {
final Map<String, dynamic> shape = getToken(getToken('$componentToken.shape') as String) as Map<String, dynamic>;
switch (shape['family']) {
case 'SHAPE_FAMILY_ROUNDED_CORNERS':
final double topLeft = shape['topLeft'] as double;
final double topRight = shape['topRight'] as double;
final double bottomLeft = shape['bottomLeft'] as double;
final double bottomRight = shape['bottomRight'] as double;
if (topLeft == topRight && topLeft == bottomLeft && topLeft == bottomRight) {
if (topLeft == 0) {
return '${prefix}RoundedRectangleBorder()';
}
return '${prefix}RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular($topLeft)))';
}
if (topLeft == topRight && bottomLeft == bottomRight) {
return '${prefix}RoundedRectangleBorder(borderRadius: BorderRadius.vertical('
'${topLeft > 0 ? 'top: Radius.circular($topLeft)':''}'
'${topLeft > 0 && bottomLeft > 0 ? ',':''}'
'${bottomLeft > 0 ? 'bottom: Radius.circular($bottomLeft)':''}'
'))';
}
return '${prefix}RoundedRectangleBorder(borderRadius: '
'BorderRadius.only('
'topLeft: Radius.circular(${shape['topLeft']}), '
'topRight: Radius.circular(${shape['topRight']}), '
'bottomLeft: Radius.circular(${shape['bottomLeft']}), '
'bottomRight: Radius.circular(${shape['bottomRight']})))';
case 'SHAPE_FAMILY_CIRCULAR':
return '${prefix}StadiumBorder()';
}
print('Unsupported shape family type: ${shape['family']} for $componentToken');
return '';
}
/// Generate a [BorderSide] for the given component.
String border(String componentToken) {
if (!tokenAvailable('$componentToken.color')) {
return 'null';
}
final String borderColor = componentColor(componentToken);
final double width = (
getToken('$componentToken.width', optional: true) ??
getToken('$componentToken.height', optional: true) ??
1.0
) as double;
return 'BorderSide(color: $borderColor${width != 1.0 ? ", width: $width" : ""})';
}
/// Generate a [TextTheme] text style name for the given component token.
String textStyle(String componentToken) {
return '$textThemePrefix${getToken("$componentToken.text-style")}';
}
String textStyleWithColor(String componentToken) {
if (!tokenAvailable('$componentToken.text-style')) {
return 'null';
}
String style = textStyle(componentToken);
if (tokenAvailable('$componentToken.color')) {
style = '$style?.copyWith(color: ${componentColor(componentToken)})';
}
return style;
}
}

View File

@ -0,0 +1,100 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:collection';
import 'dart:io';
final TokenLogger tokenLogger = TokenLogger();
/// Class to keep track of used tokens and versions.
class TokenLogger {
TokenLogger();
void init({
required Map<String, dynamic> allTokens,
required Map<String, List<String>> versionMap
}){
_allTokens = allTokens;
_versionMap = versionMap;
}
/// Map of all tokens to their values.
late Map<String, dynamic> _allTokens;
// Map of versions to their token files.
late Map<String, List<String>> _versionMap;
// Sorted set of used tokens.
final SplayTreeSet<String> _usedTokens = SplayTreeSet<String>();
// Set of tokens that were referenced on some templates, but do not exist.
final Set<String> _unavailableTokens = <String>{};
void clear() {
_allTokens.clear();
_versionMap.clear();
_usedTokens.clear();
_unavailableTokens.clear();
}
/// Logs a token.
void log(String token) {
if (!_allTokens.containsKey(token)) {
_unavailableTokens.add(token);
return;
}
_usedTokens.add(token);
}
/// Prints version usage to the console.
void printVersionUsage({required bool verbose}) {
final String versionsString = 'Versions used: ${_versionMap.keys.join(', ')}';
print(versionsString);
if (verbose) {
for (final String version in _versionMap.keys) {
print(' $version:');
final List<String> files = List<String>.from(_versionMap[version]!);
files.sort();
for (final String file in files) {
print(' $file');
}
}
print('');
}
}
/// Prints tokens usage to the console.
void printTokensUsage({required bool verbose}) {
final Set<String> allTokensSet = _allTokens.keys.toSet();
if (verbose) {
for (final String token in SplayTreeSet<String>.from(allTokensSet).toList()) {
if (_usedTokens.contains(token)) {
print('$token');
} else {
print('$token');
}
}
print('');
}
print('Tokens used: ${_usedTokens.length}/${_allTokens.length}');
if (_unavailableTokens.isNotEmpty) {
print('');
print('\x1B[31m' 'Some referenced tokens do not exist: ${_unavailableTokens.length}' '\x1B[0m');
for (final String token in _unavailableTokens) {
print(' $token');
}
}
}
/// Dumps version and tokens usage to a file.
void dumpToFile(String path) {
final File file = File(path);
file.createSync(recursive: true);
final String versionsString = 'Versions used, ${_versionMap.keys.join(', ')}';
file.writeAsStringSync('$versionsString\n${_usedTokens.join(',\n')}\n');
}
}

View File

@ -0,0 +1,19 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore: unused_element
final _ChipDefaultsM3 _chipDefaultsM3 = _ChipDefaultsM3();
// BEGIN GENERATED TOKEN PROPERTIES - Chip
// Do not edit by hand. The code between the "BEGIN GENERATED" and
// "END GENERATED" comments are generated from data in the Material
// Design token database by the script:
// dev/tools/gen_defaults/bin/gen_defaults.dart.
class _ChipDefaultsM3 {
final String testString = 'This is chip.dart class.';
}
// END GENERATED TOKEN PROPERTIES - Chip

View File

@ -45,6 +45,7 @@ void main() {
final String testRootPath = path.join('test', 'analyze-test-input', 'root');
final String dartName = Platform.isWindows ? 'dart.exe' : 'dart';
final String dartPath = path.canonicalize(path.join('..', '..', 'bin', 'cache', 'dart-sdk', 'bin', dartName));
final String testGenDefaultsPath = path.join('test', 'analyze-gen-defaults');
test('analyze.dart - verifyDeprecations', () async {
final String result = await capture(() => verifyDeprecations(testRootPath, minimumMatches: 2), shouldHaveErrors: true);
@ -293,4 +294,24 @@ void main() {
'╚═══════════════════════════════════════════════════════════════════════════════\n'
);
});
test('analyze.dart - verifyMaterialFilesAreUpToDateWithTemplateFiles', () async {
String result = await capture(() => verifyMaterialFilesAreUpToDateWithTemplateFiles(
testGenDefaultsPath,
dartPath,
), shouldHaveErrors: true);
final String lines = <String>[
'║ chip.dart is not up-to-date with the token template file.',
]
.map((String line) => line.replaceAll('/', Platform.isWindows ? r'\' : '/'))
.join('\n');
const String errorStart = '╔═';
result = result.substring(result.indexOf(errorStart));
expect(result,
'╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════\n'
'$lines\n'
'║ See: https://github.com/flutter/flutter/blob/master/dev/tools/gen_defaults to update the token template files.\n'
'╚═══════════════════════════════════════════════════════════════════════════════\n'
);
});
}

View File

@ -43,20 +43,20 @@ class _${blockName}DefaultsM3 extends InputDecorationTheme {
return ${border('md.comp.filled-text-field.disabled.active-indicator')};
}
if (states.contains(MaterialState.error)) {
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.filled-text-field.error.hover.active-indicator')};
}
if (states.contains(MaterialState.focused)) {
return ${mergedBorder('md.comp.filled-text-field.error.focus.active-indicator','md.comp.filled-text-field.focus.active-indicator')};
}
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.filled-text-field.error.hover.active-indicator')};
}
return ${border('md.comp.filled-text-field.error.active-indicator')};
}
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.filled-text-field.hover.active-indicator')};
}
if (states.contains(MaterialState.focused)) {
return ${border('md.comp.filled-text-field.focus.active-indicator')};
}
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.filled-text-field.hover.active-indicator')};
}
return ${border('md.comp.filled-text-field.active-indicator')};
});
@ -66,20 +66,20 @@ class _${blockName}DefaultsM3 extends InputDecorationTheme {
return ${border('md.comp.outlined-text-field.disabled.outline')};
}
if (states.contains(MaterialState.error)) {
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.outlined-text-field.error.hover.outline')};
}
if (states.contains(MaterialState.focused)) {
return ${mergedBorder('md.comp.outlined-text-field.error.focus.outline','md.comp.outlined-text-field.focus.outline')};
}
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.outlined-text-field.error.hover.outline')};
}
return ${border('md.comp.outlined-text-field.error.outline')};
}
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.outlined-text-field.hover.outline')};
}
if (states.contains(MaterialState.focused)) {
return ${border('md.comp.outlined-text-field.focus.outline')};
}
if (states.contains(MaterialState.hovered)) {
return ${border('md.comp.outlined-text-field.hover.outline')};
}
return ${border('md.comp.outlined-text-field.outline')};
});

View File

@ -1535,6 +1535,7 @@ class _PopupMenuDefaultsM3 extends PopupMenuThemeData {
@override MaterialStateProperty<TextStyle?>? get labelTextStyle {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
// TODO(quncheng): Update this hard-coded value to use the latest tokens.
final TextStyle style = _textTheme.labelLarge!;
if (states.contains(MaterialState.disabled)) {
return style.apply(color: _colors.onSurface.withOpacity(0.38));

View File

@ -2441,7 +2441,7 @@ class _TabsPrimaryDefaultsM3 extends TabBarTheme {
static double indicatorWeight(TabBarIndicatorSize indicatorSize) {
return switch (indicatorSize) {
TabBarIndicatorSize.label => 3.0,
TabBarIndicatorSize.tab => 2.0,
TabBarIndicatorSize.tab => 2.0,
};
}