blob: a0577bb80edddbe7007fdf3b9a4811b7c6d0f53d [file] [log] [blame]
// Copyright 2017 The Fuchsia 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:async';
import 'dart:collection';
import 'dart:io';
import 'package:analyzer/dart/constant/value.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:dart_style/dart_style.dart';
import 'package:mustache/mustache.dart';
import 'package:path/path.dart' as path;
import 'package:strings/strings.dart' as strings;
// ignore: implementation_imports
import 'package:widget_explorer_core/src/utils.dart';
import 'package:widget_explorer_core/widget_specs.dart';
final DartFormatter _formatter = new DartFormatter();
Future<Null> main(List<String> args) async {
String fuchsiaRoot = findFuchsiaRoot();
String error = checkArgs(args, fuchsiaRoot);
if (error != null) {
stderr.writeln(error);
stdout.writeln('Usage: pub run widget_explorer_gen.dart '
'<output_dir> <widgets_package_dir> [<widgets_package_dir> ...]');
exit(1);
}
String outputDir = args[0];
List<String> packageDirs = args.sublist(1);
List<WidgetSpecs> allWidgetSpecs = packageDirs
.expand((String packageDir) =>
extractWidgetSpecs(packageDir, fuchsiaRoot: fuchsiaRoot))
.toList()
..sort();
await writeIndex(outputDir, allWidgetSpecs);
await Future.forEach(
allWidgetSpecs,
(WidgetSpecs specs) => writeWidgetSpecs(outputDir, specs),
);
}
/// Try finding the fuchsia root from the current directory.
///
/// Walk up the directories until finding the .jiri_root directory. Returns null
/// if it fails to find the fuchsia root.
String findFuchsiaRoot() {
Directory current = Directory.current;
// ignore: literal_only_boolean_expressions
while (true) {
FileSystemEntity jiriRoot = current.listSync().firstWhere(
(FileSystemEntity entity) =>
path.basename(entity.path) == '.jiri_root' && entity is Directory,
orElse: () => null,
);
if (jiriRoot != null) {
return current.absolute.path;
}
// Break out if we reach the system root directory.
Directory parent = current.parent;
if (parent == current) {
break;
}
current = parent;
}
return null;
}
/// Check if the provided arguments are valid.
///
/// Returns the reason when there is an error; returns null otherwise.
String checkArgs(List<String> args, String fuchsiaRoot) {
if (args.length < 2) {
return 'Invalid number of arguments.';
}
String outputDir = args[0];
if (!new Directory(outputDir).existsSync()) {
// Try creating the directory.
try {
new Directory(outputDir).createSync(recursive: true);
} on Exception {
return 'Could not create the output directory "$outputDir".';
}
}
for (int i = 1; i < args.length; ++i) {
String packageDir = args[i];
if (!new Directory(packageDir).existsSync()) {
return 'The specified package directory "$packageDir" does not exist.';
}
if (!new File(path.join(packageDir, 'pubspec.yaml')).existsSync()) {
return 'The specified package directory "$packageDir" '
'does not contain "pubspec.yaml" file.';
}
if (fuchsiaRoot != null) {
// The fuchsia root dir should be an ancestor of the given package dir.
if (!path.isWithin(fuchsiaRoot, packageDir)) {
return 'The fuchsia root should be an ancestor of the package dir.';
}
}
}
return null;
}
const String _kHeader = '''
// Copyright 2017 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// THIS IS A GENERATED FILE. DO NOT MODIFY MANUALLY.''';
const String _kIndexFileTemplate = '''
{{ header }}
import 'package:widget_explorer_core/widget_specs.dart';
import 'package:widget_explorer_widgets/widget_explorer_widgets.dart';
{{ imports }}
/// Map of widget specs.
final Map<String, WidgetSpecs> kWidgetSpecs = <String, WidgetSpecs>{
{{ items }}
};
/// Map of generated widget state builders.
final Map<String, GeneratedStateBuilder> kStateBuilders = <String, GeneratedStateBuilder>{
{{ builders }}
};
''';
/// Writes the index file to the given output directory.
Future<Null> writeIndex(String outputDir, List<WidgetSpecs> widgetSpecs) async {
String outputPath = path.join(outputDir, 'index.dart');
Template template = new Template(
_kIndexFileTemplate,
htmlEscapeValues: false,
);
String imports = widgetSpecs.map((WidgetSpecs specs) {
String underscoredName = strings.underscore(specs.name);
return "import '$underscoredName.dart' as $underscoredName;";
}).join('\n');
String items = widgetSpecs.map((WidgetSpecs specs) {
String underscoredName = strings.underscore(specs.name);
return ' $underscoredName.kName: $underscoredName.kSpecs,';
}).join('\n');
String builders = widgetSpecs.map((WidgetSpecs specs) {
String underscoredName = strings.underscore(specs.name);
return ' $underscoredName.kName: $underscoredName.kBuilder,';
}).join('\n');
String output = template.renderString(<String, dynamic>{
'header': _kHeader,
'imports': imports,
'items': items,
'builders': builders,
});
await new File(outputPath).writeAsString(_formatter.format(output));
}
const String _kSpecFileTemplate = '''
{{ header }}
import 'package:flutter/material.dart';
import 'package:widget_explorer_core/widget_specs.dart';
import 'package:widget_explorer_widgets/widget_explorer_widgets.dart';
import 'package:{{ package_name }}/{{ path }}';
{{# additional_imports }}
import '{{ additional_import }}' as {{ import_id }};
{{/ additional_imports }}
/// Name of the widget.
const String kName = '{{ name }}';
/// [WidgetSpecs] of this widget.
final WidgetSpecs kSpecs = new WidgetSpecs(
packageName: '{{ package_name }}',
name: '{{ name }}',
path: '{{ path }}',
{{# path_from_fuchsia_root }}
pathFromFuchsiaRoot: '{{ path_from_fuchsia_root }}',
{{/ path_from_fuchsia_root }}
doc: \'\'\'
{{ doc }}\'\'\',
exampleWidth: {{ example_width }},
exampleHeight: {{ example_height }},
hasSizeParam: {{ has_size_param }},
);
/// Generated state object for this widget.
class _Generated{{ name }}State extends GeneratedState {
{{# params }}
{{ qualified_param_type }} {{ param_name }};
{{/ params }}
{{# generators }}
{{ generator_declaration }};
{{/ generators }}
_Generated{{ name }}State(SetStateFunc setState) : super(setState);
@override
void initState(Map<String, dynamic> config) {
{{# params }}
{{ param_name }} = {{ param_initial_value }};
{{/ params }}
}
@override
Widget buildWidget(
BuildContext context,
Key key,
double width,
double height,
) {
return new {{ name }}(
key: key,
{{# params }}
{{ param_name }}: {{ param_expr }},
{{/ params }}
);
}
@override
List<TableRow> buildParameterTableRows(BuildContext context) {
return <TableRow>[
{{# params }}
buildTableRow(
context,
<Widget>[
new Text('{{ param_type }}'),
new Text('{{ param_name }}'),
{{ param_controller }},
],
),
{{/ params }}
];
}
}
/// State builder for this widget.
final GeneratedStateBuilder kBuilder = (SetStateFunc setState) =>
new _Generated{{ name }}State(setState);
''';
/// Writes the widget specs to the given output directory.
Future<Null> writeWidgetSpecs(String outputDir, WidgetSpecs specs) async {
String underscoredName = strings.underscore(specs.name);
String outputPath = path.join(outputDir, '$underscoredName.dart');
Template template = new Template(
_kSpecFileTemplate,
htmlEscapeValues: false,
);
// Escape single quotes within the doc comments.
String escapedDoc = _escapeQuotes(specs.doc);
Set<String> additionalImports = new SplayTreeSet<String>();
Map<String, String> importIdMap = <String, String>{};
Set<DartType> generators = new SplayTreeSet<DartType>(
(DartType t1, DartType t2) => t1.name.compareTo(t2.name),
);
List<ParameterElement> params = <ParameterElement>[];
ConstructorElement constructor = specs.classElement.constructors.firstWhere(
(ConstructorElement c) => c.isDefaultConstructor,
orElse: () => null);
if (constructor != null) {
params = new List<ParameterElement>.from(constructor.parameters)
..removeWhere((ParameterElement param) => param.type.name == 'Key')
..forEach((ParameterElement param) =>
_addImportForType(additionalImports, importIdMap, param.type));
}
// The parameter controllers / initial values should be generated here first
// so that the additional imports can be safely added.
List<Map<String, String>> paramList = params
.map((ParameterElement param) => <String, String>{
'qualified_param_type':
_getQualifiedTypeName(importIdMap, param.type),
'param_type': _getTypeName(param.type),
'param_name': param.name,
'param_controller': _generateParamControllerCode(
additionalImports,
importIdMap,
generators,
specs,
param,
),
'param_initial_value': _generateInitialValueCode(
additionalImports,
importIdMap,
generators,
specs,
param,
),
'param_expr': _generateParameterExpression(param),
})
.toList();
List<Map<String, String>> generatorList = generators
.map((DartType generatorType) => <String, String>{
'generator_declaration':
'${_getImportIdPrefixForType(importIdMap, generatorType)}'
'${generatorType.name} '
'${lowerCamelize(generatorType.name)} = '
'new ${_getImportIdPrefixForType(importIdMap, generatorType)}'
'${generatorType.name}()',
})
.toList();
String output = template.renderString(<String, dynamic>{
'header': _kHeader,
'package_name': specs.packageName,
'name': specs.name,
'path': specs.path,
'path_from_fuchsia_root': specs.pathFromFuchsiaRoot != null
? <String, String>{'path_from_fuchsia_root': specs.pathFromFuchsiaRoot}
: null,
'doc': escapedDoc,
'example_width': _doubleValueToCode(specs.exampleWidth),
'example_height': _doubleValueToCode(specs.exampleHeight),
'has_size_param': specs.hasSizeParam,
'additional_imports': additionalImports
.map((String uri) => <String, String>{
'additional_import': uri,
'import_id': importIdMap[uri],
})
.toList(),
'params': paramList,
'generators': generatorList,
});
await new File(outputPath).writeAsString(_formatter.format(output));
}
String _generateParamControllerCode(
Set<String> additionalImports,
Map<String, String> importIdMap,
Set<DartType> generators,
WidgetSpecs specs,
ParameterElement param,
) {
// TODO(youngseokyoon): handle more types of values.
// Handle size parameters.
if (_isWidthParam(param)) {
return "new InfoText('width value is used')";
}
if (_isHeightParam(param)) {
return "new InfoText('height value is used')";
}
if (_isSizeParam(param)) {
return "new InfoText('size value is used')";
}
// For int type, use a TextField where the user can type in the integer value.
if (param.type.name == 'int') {
return '''new TextFieldWithInitialValue(
initialValue: (${param.name} ?? 0).toString(),
keyboardType: TextInputType.number,
onChanged: (String value) {
try {
int intValue = int.parse(value);
setState(() {
${param.name} = intValue;
});
} catch (e) {
// Do nothing.
}
},
)''';
}
// For bool type, use a Switch widget.
// Since we don't want the Switch widget to take up the entire width, add an
// empty widget next to it.
if (param.type.name == 'bool') {
return '''new Row(
children: <Widget>[
new Switch(
value: ${param.name} ?? false,
onChanged: (bool value) {
setState(() {
${param.name} = value;
});
},
),
new Expanded(child: new Container()),
],
)''';
}
// For double type, use a TextField where the user can type the value.
if (param.type.name == 'double') {
return '''new TextFieldWithInitialValue(
initialValue: (${param.name} ?? 0.0).toString(),
keyboardType: TextInputType.number,
onChanged: (String value) {
try {
double doubleValue = double.parse(value);
setState(() {
${param.name} = doubleValue;
});
} catch (e) {
// Do nothing.
}
},
)''';
}
// For String type, use a TextField where the user can type in the value.
if (param.type.name == 'String') {
// If this parameter should be retrieved from the config.json file, do not
// show the values on the screen.
String configKey = specs.getConfigKey(param);
if (configKey != null) {
return """new ConfigKeyText(
configKey: '${_escapeQuotes(configKey)}',
configValue: ${param.name},
)""";
}
return '''new TextFieldWithInitialValue(
initialValue: ${param.name},
onChanged: (String value) {
setState(() {
${param.name} = value;
});
},
)''';
}
// Handle enum parameters with a popup menu button.
if (_isEnumParameter(param)) {
return '''new PopupMenuButton<${_getQualifiedTypeName(importIdMap, param.type)}>(
itemBuilder: (BuildContext context) {
return ${_getQualifiedTypeName(importIdMap, param.type)}.values.map((${_getQualifiedTypeName(importIdMap, param.type)} value) {
return new PopupMenuItem<${_getQualifiedTypeName(importIdMap, param.type)}>(
value: value,
child: new Text(value.toString()),
);
}).toList();
},
initialValue: ${_getQualifiedTypeName(importIdMap, param.type)}.values[0],
onSelected: (${_getQualifiedTypeName(importIdMap, param.type)} value) {
setState(() {
${param.name} = value;
});
},
child: new Text((${param.name} ?? 'null').toString()),
)''';
}
// Handle callback parameters.
if (_isCallbackParameter(param)) {
return "new InfoText('Default implementation')";
}
// Handle parameters with a specified generator.
ElementAnnotation generatorAnnotation = _getGenerator(param);
if (generatorAnnotation != null) {
DartObject generatorObj = generatorAnnotation.computeConstantValue();
DartType generatorType = generatorObj.getField('type').toTypeValue();
String methodName = generatorObj.getField('methodName').toStringValue();
// Add the generator type to the list of additional imports and generators.
_addImportForType(additionalImports, importIdMap, generatorType);
generators.add(generatorType);
// The actual code to invoke (e.g. `modelFixtures.thread()`).
String generatorInvocationCode =
_getGeneratorInvocationCode(generatorType, methodName);
// Place a button widget for regenerating the value.
return '''new RegenerateButton(
onPressed: () {
setState(() {
${param.name} = $generatorInvocationCode;
});
},
codeToDisplay: '${_escapeQuotes(generatorInvocationCode)}',
)''';
}
return "new InfoText('null (this type of parameter is not supported yet)')";
}
String _generateInitialValueCode(
Set<String> additionalImports,
Map<String, String> importIdMap,
Set<DartType> generators,
WidgetSpecs specs,
ParameterElement param,
) {
// See if there is an example value specified.
dynamic value = specs.getExampleValue(param);
if (value != null) {
switch (value.runtimeType) {
case int:
case bool:
case double:
return value.toString();
case String:
return "'''${_escapeQuotes(value.toString())}'''";
default:
return 'null';
}
}
// Retrieve the config value associated with the specified config key.
String configKey = specs.getConfigKey(param);
if (configKey != null) {
return "config['${_escapeQuotes(configKey)}']";
}
// TODO(youngseokyoon): See if the parameter type has a default constructor
// that can be used.
// if (param.type.element is ClassElement) {
// ClassElement type = param.type.element;
// if (type.constructors
// .any((ConstructorElement c) => c.isDefaultConstructor)) {
// return 'new ${param.type.name}()';
// }
// }
// Handle primitive types.
switch (param.type.name) {
case 'int':
return '0';
case 'bool':
return 'false';
case 'double':
return '0.0';
case 'String':
return "''";
}
// Handle enum types.
if (_isEnumParameter(param)) {
return '${_getQualifiedTypeName(importIdMap, param.type)}.values[0]';
}
// Handle callback parameters.
if (_isCallbackParameter(param)) {
FunctionTypedElement func = param.type.element;
String functionName = '${specs.name}.${param.name}';
// Print out all the parameter values to the console.
if (func.parameters.isNotEmpty) {
String paramList = func.parameters
.map((ParameterElement p) => 'dynamic ${p.name}')
.join(', ');
String valueList =
func.parameters.map((ParameterElement p) => p.name).join(', ');
return "($paramList) => print('$functionName called "
"with parameters: \${<dynamic>[$valueList]}')";
}
// If the callback function has no parameters, just say it was called.
return "() => print('$functionName called')";
}
// Handle parameters with a specified generator.
ElementAnnotation generatorAnnotation = _getGenerator(param);
if (generatorAnnotation != null) {
DartObject generatorObj = generatorAnnotation.computeConstantValue();
DartType generatorType = generatorObj.getField('type').toTypeValue();
String methodName = generatorObj.getField('methodName').toStringValue();
// Place a button widget for regenerating the value.
return _getGeneratorInvocationCode(generatorType, methodName);
}
// Otherwise, return 'null';
return 'null';
}
String _generateParameterExpression(ParameterElement param) {
if (_isWidthParam(param)) {
return 'width';
}
if (_isHeightParam(param)) {
return 'height';
}
if (_isSizeParam(param)) {
// In this case, the width and height value must be the same, and it doesn't
// matter which one we use. Just using the width value here.
return 'width';
}
return 'this.${param.name}';
}
/// Returns the display name of the given type.
///
/// If the type has generic type arguments, returns 'dynamic' instead, to avoid
/// having to deal with analyzer errors for now.
String _getTypeName(DartType type) {
// TODO(youngseokyoon): Handle generic type arguments correctly.
// https://fuchsia.atlassian.net/browse/SO-259
if (type is ParameterizedType) {
ParameterizedType parameterizedType = type;
if (parameterizedType.typeArguments?.isNotEmpty ?? false) {
return 'dynamic';
}
}
return type.name;
}
/// Determines whether the provided parameter is of an enum type.
bool _isEnumParameter(ParameterElement param) {
if (param?.type?.element is! ClassElement) {
return false;
}
ClassElement paramType = param.type.element;
return paramType.isEnum;
}
/// Determines whether the provided parameter represents a callback function.
///
/// We consider any function parameter with a void return type as a callback.
bool _isCallbackParameter(ParameterElement param) {
if (param?.type?.element is! FunctionTypedElement) {
return false;
}
FunctionTypedElement func = param.type.element;
return func.returnType.isVoid;
}
/// Gets the @Generator annotation of the given parameter.
ElementAnnotation _getGenerator(ParameterElement param) {
ElementAnnotation annotation;
// An @Generator annotation on the parameter itself has a higher priority.
annotation = getAnnotationWithName(param, 'Generator');
if (annotation != null) {
return annotation;
}
// Also see if the parameter type (class) has an @Generator annotation.
return annotation = getAnnotationWithName(param?.type?.element, 'Generator');
}
/// Gets the code for invoking the generator.
String _getGeneratorInvocationCode(DartType generatorType, String methodName) {
return '${lowerCamelize(generatorType.name)}.$methodName()';
}
bool _isWidthParam(ParameterElement param) {
return getAnnotationWithName(param, '_WidthParam') != null;
}
bool _isHeightParam(ParameterElement param) {
return getAnnotationWithName(param, '_HeightParam') != null;
}
bool _isSizeParam(ParameterElement param) {
return getAnnotationWithName(param, '_SizeParam') != null;
}
/// Escape all single quotes in the given string with a leading backslash,
/// except for the ones already escaped.
String _escapeQuotes(String str) {
return str?.replaceAllMapped(
new RegExp(r"([^\\])'"),
(Match m) => "${m.group(1)}\\\'",
);
}
void _addImportForType(
Set<String> additionalImports,
Map<String, String> importIdMap,
DartType type,
) {
Uri importUri = type?.element?.librarySource?.uri;
String importUriString = importUri?.toString();
if (importUriString != null &&
importUriString != 'dart:core' &&
!additionalImports.contains(importUriString)) {
additionalImports.add(importUriString);
// Specify an identifier (i.e. import '..' as foo) to avoid name collision.
String idBase = strings.underscore(
path.basenameWithoutExtension(importUri.pathSegments.last),
);
String id = idBase;
// Here, just in case the import id name is already in use, add a number at
// the end of the id name by increasing the number until it doesn't collide
// with an existing id.
int count = 1;
while (importIdMap.values.contains(id)) {
++count;
id = '$idBase$count';
}
importIdMap[importUriString] = id;
}
}
/// Returns the import identifier prefix for the given type.
///
/// For example, if "foo.dart" was imported as "foo",
/// import 'foo.dart' as foo;
///
/// and the `Foo` type was given as the second parameter, this function returns
/// `'foo.'` with the trailing dot.
///
/// Otherwise, this function returns empty string.
String _getImportIdPrefixForType(
Map<String, String> importIdMap,
DartType type,
) {
String importUriString = type?.element?.librarySource?.uri?.toString();
String importId = importIdMap[importUriString];
return importId != null ? '$importId.' : '';
}
/// Returns the fully qualified name for the given type.
String _getQualifiedTypeName(
Map<String, String> importIdMap,
DartType type,
) {
String typeName = _getTypeName(type);
String prefix =
typeName == 'dynamic' ? '' : _getImportIdPrefixForType(importIdMap, type);
return '$prefix$typeName';
}
String _doubleValueToCode(double value) {
if (value == double.nan) {
return 'double.NAN';
} else if (value == double.infinity) {
return 'double.INFINITY';
} else if (value == double.negativeInfinity) {
return 'double.NEGATIVE_INFINITY';
} else if (value == double.minPositive) {
return 'double.MIN_POSITIVE';
} else if (value == double.maxFinite) {
return 'double.MAX_FINITE';
} else if (value == null) {
return 'null';
}
return value.toString();
}