blob: ccb78a62195c207d67fe4345d370a8fc2f663103 [file] [log] [blame]
// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file
// for details. 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:math';
import 'package:collection/collection.dart';
import 'json_schema.dart';
import 'json_schema_extensions.dart';
/// Generates Dart classes from the Debug Adapter Protocol's JSON Schema.
class CodeGenerator {
/// Writes all required Dart classes for the supplied DAP [schema].
void writeAll(IndentableStringBuffer buffer, JsonSchema schema) {
_writeDefinitionClasses(buffer, schema);
buffer.writeln();
_writeBodyClasses(buffer, schema);
buffer.writeln();
_writeEventTypeLookup(buffer, schema);
buffer.writeln();
_writeCommandArgumentTypeLookup(buffer, schema);
}
/// Maps a name used in the DAP spec to a valid name for use in Dart.
///
/// Reserved words like `default` will be mapped to a suitable alternative.
/// Prefixed underscores are removed to avoid making things private.
///
/// Underscores between words are swapped for camelCase.
String _dartSafeName(String name) {
const improvedName = {
'default': 'defaultValue',
};
return improvedName[name] ??
// Some types are prefixed with _ in the spec but that will make them
// private in Dart and inaccessible to the adapter so we strip it off.
name
.replaceAll(RegExp(r'^_+'), '')
// Also replace any other underscores to make camelCase
.replaceAllMapped(
RegExp(r'_(.)'), (m) => m.group(1)!.toUpperCase());
}
/// Re-wraps [lines] at [maxLength] to help keep comments for indented code
/// within 80 characters.
Iterable<String> _wrapLines(List<String> lines, int maxLength) sync* {
lines = lines.map((l) => l.trimRight()).toList();
for (var line in lines) {
while (true) {
if (line.length <= maxLength || line.startsWith('-')) {
yield line;
break;
} else {
var lastSpace = line.lastIndexOf(' ', max(maxLength, 0));
// If there was no valid place to wrap, yield the whole string.
if (lastSpace == -1) {
yield line;
break;
} else {
yield line.substring(0, lastSpace);
line = line.substring(lastSpace + 1);
}
}
}
}
}
/// For each Response/Event class in the spec, generate a specific class to
/// represent its body.
///
/// These classes are used to simplify sending responses/events from the
/// Debug Adapters by avoiding the need to construct the entire response/event
/// which requires additional fields (for example the corresponding requests
/// id/command and sequences):
///
/// this.sendResponse(FooBody(x: 1))
///
/// instead of
///
/// this.sendResponse(Response(
/// seq: seq++,
/// request_seq: request.seq,
/// command: request.command,
/// body: {
/// x: 1
/// ...
/// }
/// ))
void _writeBodyClasses(IndentableStringBuffer buffer, JsonSchema schema) {
for (final entry in schema.definitions.entries.sortedBy((e) => e.key)) {
final name = entry.key;
final type = entry.value;
final baseType = type.baseType;
if (baseType?.refName == 'Response' || baseType?.refName == 'Event') {
final baseClass = baseType?.refName == 'Event'
? JsonType.named(schema, 'EventBody')
: null;
final classProperties = schema.propertiesFor(type);
final bodyProperty = classProperties['body'];
var bodyPropertyProperties = bodyProperty?.properties;
_writeClass(
buffer,
bodyProperty ?? JsonType.empty(schema),
'${name}Body',
bodyPropertyProperties ?? {},
{},
baseClass,
null,
);
}
}
}
/// Writes a `canParse` function for a DAP spec class.
///
/// The function checks whether an Object? is a a valid map that contains all
/// required fields and matches the types of the spec class.
///
/// This is used where the spec contains union classes and we need to decide
/// which of the allowed types a given value is.
void _writeCanParseMethod(
IndentableStringBuffer buffer,
JsonType type,
Map<String, JsonType> properties, {
required String? baseTypeRefName,
}) {
buffer
..writeIndentedln('static bool canParse(Object? obj) {')
..indent()
..writeIndentedln('if (obj is! Map<String, dynamic>) {')
..indent()
..writeIndentedln('return false;')
..outdent()
..writeIndentedln('}');
// In order to consider this valid for parsing, all fields that must not be
// undefined must be present and also type check for the correct type.
// Any fields that are optional but present, must still type check.
for (final entry in properties.entries.sortedBy((e) => e.key)) {
final propertyName = entry.key;
final propertyType = entry.value;
final isOptional = !type.requiresField(propertyName);
if (propertyType.isAny && isOptional) {
continue;
}
buffer.writeIndented('if (');
_writeTypeCheckCondition(buffer, propertyType, "obj['$propertyName']",
isOptional: isOptional, invert: true);
buffer
..writeln(') {')
..indent()
..writeIndentedln('return false;')
..outdent()
..writeIndentedln('}');
}
buffer
..writeIndentedln(
baseTypeRefName != null
? 'return $baseTypeRefName.canParse(obj);'
: 'return true;',
)
..outdent()
..writeIndentedln('}');
}
/// Writes the Dart class for [type].
void _writeClass(
IndentableStringBuffer buffer,
JsonType type,
String name,
Map<String, JsonType> classProperties,
Map<String, JsonType> baseProperties,
JsonType? baseType,
JsonType? resolvedBaseType, {
Map<String, String> additionalValues = const {},
}) {
// Some properties are defined in both the base and the class, because the
// type may be narrowed, but sometimes we only want those that are defined
// only in this class.
final classOnlyProperties = {
for (final property in classProperties.entries)
if (!baseProperties.containsKey(property.key))
property.key: property.value,
};
_writeTypeDescription(buffer, type);
buffer.write('class $name ');
if (baseType != null) {
buffer.write('extends ${baseType.refName} ');
}
buffer
..writeln('{')
..indent();
for (final val in additionalValues.entries) {
buffer
..writeIndentedln('@override')
..writeIndentedln("final ${val.key} = '${val.value}';");
}
_writeFields(buffer, type, classOnlyProperties);
buffer.writeln();
_writeFromJsonStaticMethod(buffer, name);
buffer.writeln();
_writeConstructor(buffer, name, type, classProperties, baseProperties,
classOnlyProperties,
baseType: resolvedBaseType);
buffer.writeln();
_writeFromMapConstructor(buffer, name, type, classOnlyProperties,
callSuper: resolvedBaseType != null);
buffer.writeln();
_writeCanParseMethod(buffer, type, classProperties,
baseTypeRefName: baseType?.refName);
buffer.writeln();
_writeToJsonMethod(buffer, name, type, classOnlyProperties,
callSuper: resolvedBaseType != null);
buffer
..outdent()
..writeln('}')
..writeln();
}
/// Write a map to look up the `command` for a given `RequestArguments` type
/// to simplify sending requests back to the client:
///
/// this.sendRequest(FooArguments(x: 1))
///
/// instead of
///
/// this.sendRequest(Request(
/// seq: seq++,
/// command: request.command,
/// arguments: {
/// x: 1
/// ...
/// }
/// ))
void _writeCommandArgumentTypeLookup(
IndentableStringBuffer buffer, JsonSchema schema) {
buffer
..writeln('const commandTypes = {')
..indent();
for (final entry in schema.definitions.entries.sortedBy((e) => e.key)) {
final type = entry.value;
final baseType = type.baseType;
if (baseType?.refName == 'Request') {
final classProperties = schema.propertiesFor(type);
final argumentsProperty = classProperties['arguments'];
final commandType = classProperties['command']?.literalValue;
if (argumentsProperty?.dollarRef != null && commandType != null) {
buffer.writeIndentedln(
"${argumentsProperty!.refName}: '$commandType',");
}
}
}
buffer
..writeln('};')
..outdent();
}
/// Writes a constructor for [type].
///
/// The constructor will have named arguments for all fields, with those that
/// are mandatory marked with `required`.
void _writeConstructor(
IndentableStringBuffer buffer,
String name,
JsonType type,
Map<String, JsonType> classProperties,
Map<String, JsonType> baseProperties,
Map<String, JsonType> classOnlyProperties, {
required JsonType? baseType,
}) {
buffer.writeIndented('$name(');
if (classProperties.isNotEmpty || baseProperties.isNotEmpty) {
buffer
..writeln('{')
..indent();
// Properties for this class are written as 'this.foo'.
for (final entry in classOnlyProperties.entries.sortedBy((e) => e.key)) {
final propertyName = entry.key;
final fieldName = _dartSafeName(propertyName);
final isOptional = !type.requiresField(propertyName);
buffer.writeIndented('');
if (!isOptional) {
buffer.write('required ');
}
buffer.writeln('this.$fieldName, ');
}
// Properties from the base class are standard arguments that will be
// passed to a super() call.
for (final entry in baseProperties.entries.sortedBy((e) => e.key)) {
final propertyName = entry.key;
// If this field is defined by the class and the base, prefer the
// class one as it may contain things like the literal values.
final propertyType = classProperties[propertyName] ?? entry.value;
final fieldName = _dartSafeName(propertyName);
if (propertyType.literalValue != null) {
continue;
}
final isOptional = !type.requiresField(propertyName);
final dartType = propertyType.asDartType(isOptional: isOptional);
buffer.writeIndented('');
if (!isOptional) {
buffer.write('required ');
}
buffer.writeln('$dartType $fieldName, ');
}
buffer
..outdent()
..writeIndented('}');
}
buffer.write(')');
if (baseType != null) {
buffer.write(': super(');
if (baseProperties.isNotEmpty) {
buffer
..writeln()
..indent();
for (final entry in baseProperties.entries) {
final propertyName = entry.key;
// Skip any properties that have literal values defined by the base
// as we won't need to supply them.
if (entry.value.literalValue != null) {
continue;
}
// If this field is defined by the class and the base, prefer the
// class one as it may contain things like the literal values.
final propertyType = classProperties[propertyName] ?? entry.value;
final fieldName = _dartSafeName(propertyName);
final literalValue = propertyType.literalValue;
final value = literalValue != null ? "'$literalValue'" : fieldName;
buffer.writeIndentedln('$fieldName: $value, ');
}
buffer
..outdent()
..writeIndented('');
}
buffer.write(')');
}
buffer.writeln(';');
}
/// Write a class for each item in the DAP spec.
///
/// Skips over the Request and Event sub-classes, as they are handled by the
/// simplified body classes written by [_writeBodyClasses]. Uses
/// [RequestArguments] as the base class for all argument classes.
void _writeDefinitionClasses(
IndentableStringBuffer buffer, JsonSchema schema) {
for (final entry in schema.definitions.entries.sortedBy((e) => e.key)) {
final name = entry.key;
final type = entry.value;
var baseType = type.baseType;
final resolvedBaseType =
baseType != null ? schema.typeFor(baseType) : null;
final classProperties = schema.propertiesFor(type, includeBase: false);
final baseProperties = resolvedBaseType != null
? schema.propertiesFor(resolvedBaseType)
: <String, JsonType>{};
// Skip creation of Request sub-classes, as we don't use these we just
// pass the arguments in to the method directly.
if (name != 'Request' && name.endsWith('Request')) {
continue;
}
// Skip creation of Event sub-classes, as we don't use these we just
// pass the body in to sendEvent directly.
if (name != 'Event' && name.endsWith('Event')) {
continue;
}
// Create a synthetic base class for arguments to provide type safety
// for sending requests.
if (baseType == null && name.endsWith('Arguments')) {
baseType = JsonType.named(schema, 'RequestArguments');
}
_writeClass(
buffer,
type,
name,
classProperties,
baseProperties,
baseType,
resolvedBaseType,
);
}
}
/// Writes a DartDoc comment, wrapped at 80 characters taking into account
/// the indentation.
void _writeDescription(IndentableStringBuffer buffer, String? description) {
final maxLength = 80 - buffer.totalIndent - 4;
if (description != null) {
for (final line in _wrapLines(description.split('\n'), maxLength)) {
buffer.writeIndentedln('/// $line');
}
}
}
/// Write a map to look up the `event` for a given `EventBody` type
/// to simplify sending events back to the client:
///
/// this.sendEvent(FooEvent(x: 1))
///
/// instead of
///
/// this.sendEvent(Event(
/// seq: seq++,
/// event: 'FooEvent',
/// arguments: {
/// x: 1
/// ...
/// }
/// ))
void _writeEventTypeLookup(IndentableStringBuffer buffer, JsonSchema schema) {
buffer
..writeln('const eventTypes = {')
..indent();
for (final entry in schema.definitions.entries.sortedBy((e) => e.key)) {
final name = entry.key;
final type = entry.value;
final baseType = type.baseType;
if (baseType?.refName == 'Event') {
final classProperties = schema.propertiesFor(type);
final eventType = classProperties['event']!.literalValue;
buffer.writeIndentedln("${name}Body: '$eventType',");
}
}
buffer
..writeln('};')
..outdent();
}
/// Writes Dart fields for [properties], taking into account whether they are
/// required for [type].
void _writeFields(IndentableStringBuffer buffer, JsonType type,
Map<String, JsonType> properties) {
for (final entry in properties.entries.sortedBy((e) => e.key)) {
final propertyName = entry.key;
final fieldName = _dartSafeName(propertyName);
final propertyType = entry.value;
final isOptional = !type.requiresField(propertyName);
final dartType = propertyType.asDartType(isOptional: isOptional);
_writeDescription(buffer, propertyType.description);
buffer.writeIndentedln('final $dartType $fieldName;');
}
}
/// Writes an expression to deserialise a [valueCode].
///
/// If [type] represents a spec type, it's `fromJson` function will be called.
/// If [type] is a [List], it will be mapped over this function again.
/// If [type] is an union, the appropriate `canParse` functions will be used to
/// determine which `fromJson` function to call.
void _writeFromJsonExpression(
IndentableStringBuffer buffer, JsonType type, String valueCode,
{bool isOptional = false}) {
final dartType = type.asDartType(isOptional: isOptional);
final dartTypeNotNullable = type.asDartType();
final nullOp = isOptional ? '?' : '';
if (type.isAny || type.isSimple) {
buffer.write('$valueCode');
if (dartType != 'Object?') {
buffer.write(' as $dartType');
}
} else if (type.isList) {
buffer.write('($valueCode as List$nullOp)$nullOp.map((item) => ');
_writeFromJsonExpression(buffer, type.items!, 'item');
buffer.write(').toList()');
} else if (type.isUnion) {
final types = type.unionTypes;
// Write a check against each type, e.g.:
// x is y ? new Either.tx(x) : (...)
for (var i = 0; i < types.length; i++) {
final isLast = i == types.length - 1;
// For the last item, if we're optional we won't wrap if in a check, as
// the constructor will only be called if canParse() returned true, so
// it'll the only remaining option.
if (!isLast || isOptional) {
_writeTypeCheckCondition(buffer, types[i], valueCode,
isOptional: false);
buffer.write(' ? ');
}
buffer.write('$dartTypeNotNullable.t${i + 1}(');
_writeFromJsonExpression(buffer, types[i], valueCode);
buffer.write(')');
if (!isLast) {
buffer.write(' : ');
} else if (isLast && isOptional) {
buffer.write(' : null');
}
}
} else if (type.isSpecType) {
if (isOptional) {
buffer.write('$valueCode == null ? null : ');
}
buffer.write(
'$dartTypeNotNullable.fromJson($valueCode as Map<String, Object?>)');
} else {
throw 'Unable to type check $valueCode against $type';
}
}
/// Writes a static `fromJson` method that converts an object into a spec type
/// by calling its fromMap constructor.
///
/// This is a helper method used as a tear-off since the constructor cannot be.
void _writeFromJsonStaticMethod(
IndentableStringBuffer buffer,
String name,
) =>
buffer.writeIndentedln(
'static $name fromJson(Map<String, Object?> obj) => $name.fromMap(obj);');
/// Writes a fromMap constructor to construct an object from a JSON map.
void _writeFromMapConstructor(
IndentableStringBuffer buffer,
String name,
JsonType type,
Map<String, JsonType> properties, {
bool callSuper = false,
}) {
buffer.writeIndented('$name.fromMap(Map<String, Object?> obj)');
if (properties.isNotEmpty || callSuper) {
buffer
..writeln(':')
..indent();
var isFirst = true;
for (final entry in properties.entries.sortedBy((e) => e.key)) {
if (isFirst) {
isFirst = false;
} else {
buffer.writeln(',');
}
final propertyName = entry.key;
final fieldName = _dartSafeName(propertyName);
final propertyType = entry.value;
final isOptional = !type.requiresField(propertyName);
buffer.writeIndented('$fieldName = ');
_writeFromJsonExpression(buffer, propertyType, "obj['$propertyName']",
isOptional: isOptional);
}
if (callSuper) {
if (!isFirst) {
buffer.writeln(',');
}
buffer.writeIndented('super.fromMap(obj)');
}
buffer.outdent();
}
buffer.writeln(';');
}
/// Writes a toJson method to construct a JSON map for this class, recursively
/// calling through base classes.
void _writeToJsonMethod(
IndentableStringBuffer buffer,
String name,
JsonType type,
Map<String, JsonType> properties, {
bool callSuper = false,
}) {
if (callSuper) {
buffer.writeIndentedln('@override');
}
buffer
..writeIndentedln('Map<String, Object?> toJson() => {')
..indent();
if (callSuper) {
buffer.writeIndentedln('...super.toJson(),');
}
for (final entry in properties.entries.sortedBy((e) => e.key)) {
final propertyName = entry.key;
final fieldName = _dartSafeName(propertyName);
final isOptional = !type.requiresField(propertyName);
buffer.writeIndented('');
if (isOptional) {
buffer.write('if ($fieldName != null) ');
}
buffer.writeln("'$propertyName': $fieldName, ");
}
buffer
..outdent()
..writeIndentedln('};');
}
/// Writes an expression that checks whether [valueCode] represents a [type].
void _writeTypeCheckCondition(
IndentableStringBuffer buffer, JsonType type, String valueCode,
{required bool isOptional, bool invert = false}) {
final dartType = type.asDartType(isOptional: isOptional);
// When the expression is inverted, invert the operators so the generated
// code is easier to read.
final opBang = invert ? '!' : '';
final opTrue = invert ? 'false' : 'true';
final opIs = invert ? 'is!' : 'is';
final opEquals = invert ? '!=' : '==';
final opAnd = invert ? '||' : '&&';
final opOr = invert ? '&&' : '||';
final opEvery = invert ? 'any' : 'every';
if (type.isAny) {
buffer.write(opTrue);
} else if (dartType == 'Null') {
buffer.write('$valueCode $opEquals null');
} else if (type.isSimple) {
buffer.write('$valueCode $opIs $dartType');
} else if (type.isList) {
buffer.write('($valueCode $opIs List');
buffer.write(' $opAnd ($valueCode.$opEvery((item) => ');
_writeTypeCheckCondition(buffer, type.items!, 'item',
isOptional: false, invert: invert);
buffer.write('))');
buffer.write(')');
} else if (type.isUnion) {
final types = type.unionTypes;
// To type check a union, we recursively check against each of its types.
buffer.write('(');
for (var i = 0; i < types.length; i++) {
if (i != 0) {
buffer.write(' $opOr ');
}
_writeTypeCheckCondition(buffer, types[i], valueCode,
isOptional: false, invert: invert);
}
if (isOptional) {
buffer.write(' $opOr $valueCode $opEquals null');
}
buffer.write(')');
} else if (type.isSpecType) {
buffer.write('$opBang${type.asDartType()}.canParse($valueCode)');
} else {
throw 'Unable to type check $valueCode against $type';
}
}
/// Writes the description for [type], looking at the base type from the
/// DAP spec if necessary.
void _writeTypeDescription(IndentableStringBuffer buffer, JsonType type) {
// In the DAP spec, many of the descriptions are on one of the allOf types
// rather than the type itself.
final description = type.description ??
type.allOf
?.firstWhereOrNull((element) => element.description != null)
?.description;
_writeDescription(buffer, description);
}
}
/// A [StringBuffer] with support for indenting.
class IndentableStringBuffer extends StringBuffer {
int _indentLevel = 0;
final int _indentSpaces = 2;
int get totalIndent => _indentLevel * _indentSpaces;
String get _indentString => ' ' * totalIndent;
void indent() => _indentLevel++;
void outdent() => _indentLevel--;
void writeIndented(Object obj) {
write(_indentString);
write(obj);
}
void writeIndentedln(Object obj) {
write(_indentString);
writeln(obj);
}
}