// Copyright (c) 2023, 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 _wrapLines(List 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 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 properties, { required String? baseTypeRefName, }) { buffer ..writeIndentedln('static bool canParse(Object? obj) {') ..indent() ..writeIndentedln('if (obj is! Map) {') ..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 classProperties, Map baseProperties, JsonType? baseType, JsonType? resolvedBaseType, { Map additionalValues = const {}, }) { _writeTypeDescription(buffer, type); // Types that are just aliases to simple value types should be written as // typedefs. if (type.isSimpleValue) { buffer.writeln('typedef $name = ${type.asDartType()};'); return; } // 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, }; 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 classProperties, Map baseProperties, Map 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 super-parameters. 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); buffer.writeIndented(''); if (!isOptional) { buffer.write('required '); } buffer.writeln('super.$fieldName, '); } buffer ..outdent() ..writeIndented('}'); } buffer.write(')'); // We might still need an explicit super() call if we have literal values // to pass through. if (baseType != null) { final requiredExplicitSuperArgs = baseProperties.entries .map((entry) => MapEntry(entry.key, classProperties[entry.key]?.literalValue)) .where((entry) => entry.value != null) .toList(); if (requiredExplicitSuperArgs.isNotEmpty) { buffer ..write(': super(') ..writeln() ..indent(); for (final entry in requiredExplicitSuperArgs) { final name = entry.key; final value = entry.value; buffer.writeIndentedln('$name: \'$value\', '); } buffer ..outdent() ..writeIndented('') ..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) : {}; // Skip creation of Request sub-classes, as we don't use these we just // pass the arguments into 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 into 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 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 deserialize 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 baseType = type.aliasFor ?? type; final dartType = type.asDartType(isOptional: isOptional); final dartTypeNotNullable = type.asDartType(); final nullOp = isOptional ? '?' : ''; if (baseType.isAny || baseType.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)'); } 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 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 properties, { bool callSuper = false, }) { buffer ..writeIndented('$name.fromMap(') ..write(callSuper ? 'super.obj' : 'Map obj') ..write(')'); 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()'); } 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 properties, { bool callSuper = false, }) { if (callSuper) { buffer.writeIndentedln('@override'); } buffer ..writeIndentedln('Map 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 baseType = type.aliasFor ?? type; 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 (baseType.isAny) { buffer.write(opTrue); } else if (dartType == 'Null') { buffer.write('$valueCode $opEquals null'); } else if (baseType.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); } }