208 lines
7.3 KiB
Dart
208 lines
7.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:analyzer/dart/analysis/utilities.dart';
|
|
import 'package:analyzer/dart/ast/ast.dart';
|
|
import 'package:analyzer/dart/ast/visitor.dart';
|
|
import 'package:analyzer/source/line_info.dart';
|
|
|
|
const _canonicalAppIconPath = 'lib/widgets/app_icon.dart';
|
|
const _generatedSuffixes = ['.g.dart', '.freezed.dart', '.gen.dart'];
|
|
final _generatedHeader = RegExp(
|
|
r'^\s*///?\s*(?:auto-)?generated\b.*\bdo not (?:edit|modify)\b',
|
|
caseSensitive: false,
|
|
multiLine: true,
|
|
);
|
|
|
|
void main(List<String> arguments) {
|
|
final scriptDirectory = File.fromUri(Platform.script).absolute.parent;
|
|
late final Directory root;
|
|
if (arguments.isEmpty) {
|
|
root = scriptDirectory.parent;
|
|
} else if (arguments.length == 2 && arguments.first == '--root') {
|
|
root = Directory(arguments.last).absolute;
|
|
} else {
|
|
stderr.writeln('Usage: dart run scripts/check_icon_consistency.dart [--root <path>]');
|
|
exitCode = 64;
|
|
return;
|
|
}
|
|
final libDirectory = Directory('${root.path}${Platform.pathSeparator}lib');
|
|
if (!libDirectory.existsSync()) {
|
|
stderr.writeln('lib:1:1: lib directory not found');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
final files =
|
|
libDirectory
|
|
.listSync(recursive: true, followLinks: false)
|
|
.whereType<File>()
|
|
.where((file) => file.path.endsWith('.dart'))
|
|
.toList()
|
|
..sort((a, b) => a.path.compareTo(b.path));
|
|
|
|
final failures = <_Failure>[];
|
|
var scannedFileCount = 0;
|
|
for (final file in files) {
|
|
final relativePath = _relativePath(file.path, root.path);
|
|
final source = file.readAsStringSync();
|
|
if (_isGenerated(relativePath, source)) continue;
|
|
|
|
scannedFileCount++;
|
|
final parseResult = parseString(content: source, path: file.path, throwIfDiagnostics: false);
|
|
final unit = parseResult.unit;
|
|
unit.accept(
|
|
_IconConsistencyVisitor(
|
|
path: relativePath,
|
|
lineInfo: parseResult.lineInfo,
|
|
allowFlutterIcon: relativePath == _canonicalAppIconPath,
|
|
flutterIconPrefixes: _importPrefixes(unit, const {
|
|
'package:flutter/cupertino.dart',
|
|
'package:flutter/material.dart',
|
|
'package:flutter/widgets.dart',
|
|
}),
|
|
materialPrefixes: _importPrefixes(unit, const {'package:flutter/material.dart'}),
|
|
symbolsPrefixes: _importPrefixes(unit, const {
|
|
'package:material_symbols_icons/material_symbols_icons.dart',
|
|
'package:material_symbols_icons/symbols.dart',
|
|
}),
|
|
failures: failures,
|
|
),
|
|
);
|
|
}
|
|
|
|
failures.sort((a, b) {
|
|
final pathComparison = a.path.compareTo(b.path);
|
|
if (pathComparison != 0) return pathComparison;
|
|
final lineComparison = a.line.compareTo(b.line);
|
|
if (lineComparison != 0) return lineComparison;
|
|
final columnComparison = a.column.compareTo(b.column);
|
|
if (columnComparison != 0) return columnComparison;
|
|
return a.message.compareTo(b.message);
|
|
});
|
|
|
|
if (failures.isNotEmpty) {
|
|
for (final failure in failures) {
|
|
stderr.writeln(failure);
|
|
}
|
|
stderr.writeln('Icon consistency check failed with ${failures.length} violation(s).');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
stdout.writeln('Icon consistency check passed ($scannedFileCount non-generated files scanned).');
|
|
}
|
|
|
|
bool _isGenerated(String relativePath, String source) {
|
|
final fileName = relativePath.split('/').last;
|
|
if (_generatedSuffixes.any(fileName.endsWith)) return true;
|
|
if (relativePath.contains('/generated/')) return true;
|
|
|
|
final headerLength = source.length < 1024 ? source.length : 1024;
|
|
return _generatedHeader.hasMatch(source.substring(0, headerLength));
|
|
}
|
|
|
|
String _relativePath(String path, String rootPath) {
|
|
final normalizedPath = path.replaceAll(r'\', '/');
|
|
final normalizedRoot = rootPath.replaceAll(r'\', '/');
|
|
return normalizedPath.substring(normalizedRoot.length + 1);
|
|
}
|
|
|
|
Set<String> _importPrefixes(CompilationUnit unit, Set<String> uris) {
|
|
final prefixes = <String>{};
|
|
for (final directive in unit.directives.whereType<ImportDirective>()) {
|
|
final prefix = directive.prefix;
|
|
if (prefix != null && uris.contains(directive.uri.stringValue)) {
|
|
prefixes.add(prefix.name);
|
|
}
|
|
}
|
|
return prefixes;
|
|
}
|
|
|
|
class _IconConsistencyVisitor extends RecursiveAstVisitor<void> {
|
|
_IconConsistencyVisitor({
|
|
required this.path,
|
|
required this.lineInfo,
|
|
required this.allowFlutterIcon,
|
|
required this.flutterIconPrefixes,
|
|
required this.materialPrefixes,
|
|
required this.symbolsPrefixes,
|
|
required this.failures,
|
|
});
|
|
|
|
final String path;
|
|
final LineInfo lineInfo;
|
|
final bool allowFlutterIcon;
|
|
final Set<String> flutterIconPrefixes;
|
|
final Set<String> materialPrefixes;
|
|
final Set<String> symbolsPrefixes;
|
|
final List<_Failure> failures;
|
|
|
|
@override
|
|
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
|
if (!allowFlutterIcon && node.constructorName.type.name.lexeme == 'Icon') {
|
|
_report(node.constructorName.type, 'Flutter Icon construction is forbidden; use AppIcon instead');
|
|
}
|
|
super.visitInstanceCreationExpression(node);
|
|
}
|
|
|
|
@override
|
|
void visitConstructorReference(ConstructorReference node) {
|
|
if (!allowFlutterIcon && node.constructorName.type.name.lexeme == 'Icon') {
|
|
_report(node.constructorName.type, 'Flutter Icon constructor tear-offs are forbidden; use AppIcon instead');
|
|
}
|
|
super.visitConstructorReference(node);
|
|
}
|
|
|
|
@override
|
|
void visitPrefixedIdentifier(PrefixedIdentifier node) {
|
|
final prefix = node.prefix.name;
|
|
final member = node.identifier.name;
|
|
if (prefix == 'Icons') {
|
|
_report(node, 'Icons.$member is forbidden; use a rounded Symbols member');
|
|
} else if (prefix == 'Symbols' && !member.endsWith('_rounded')) {
|
|
_report(node, 'Symbols.$member must use its _rounded counterpart');
|
|
} else if (!allowFlutterIcon && prefix == 'Icon' && member == 'new') {
|
|
_report(node, 'Flutter Icon constructor tear-offs are forbidden; use AppIcon instead');
|
|
}
|
|
super.visitPrefixedIdentifier(node);
|
|
}
|
|
|
|
@override
|
|
void visitPropertyAccess(PropertyAccess node) {
|
|
final target = node.target;
|
|
if (target is PrefixedIdentifier) {
|
|
final importPrefix = target.prefix.name;
|
|
final typeName = target.identifier.name;
|
|
final member = node.propertyName.name;
|
|
if (typeName == 'Icons' && materialPrefixes.contains(importPrefix)) {
|
|
_report(node, '$importPrefix.Icons.$member is forbidden; use a rounded Symbols member');
|
|
} else if (typeName == 'Symbols' && symbolsPrefixes.contains(importPrefix) && !member.endsWith('_rounded')) {
|
|
_report(node, '$importPrefix.Symbols.$member must use its _rounded counterpart');
|
|
} else if (!allowFlutterIcon &&
|
|
typeName == 'Icon' &&
|
|
member == 'new' &&
|
|
flutterIconPrefixes.contains(importPrefix)) {
|
|
_report(node, 'Flutter Icon constructor tear-offs are forbidden; use AppIcon instead');
|
|
}
|
|
}
|
|
super.visitPropertyAccess(node);
|
|
}
|
|
|
|
void _report(AstNode node, String message) {
|
|
final location = lineInfo.getLocation(node.offset);
|
|
failures.add(_Failure(path: path, line: location.lineNumber, column: location.columnNumber, message: message));
|
|
}
|
|
}
|
|
|
|
class _Failure {
|
|
const _Failure({required this.path, required this.line, required this.column, required this.message});
|
|
|
|
final String path;
|
|
final int line;
|
|
final int column;
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => '$path:$line:$column: $message';
|
|
}
|