blob: 6f32732c88564cb10db6006b576fbad6c23abd50 [file]
// Copyright (c) 2024, 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 'package:dart_model/dart_model.dart';
import 'package:macro/macro.dart';
import 'package:macro_service/macro_service.dart';
import 'templating.dart';
/// A macro which adds a `fromJson(Map<String, Object?> json)` JSON decoding
/// constructor to a class.
class JsonCodable {
const JsonCodable();
}
final _jsonMapTypeForLiteral = '<{{dart:core#String}}, {{dart:core#Object}}?>';
final _jsonMapType = '{{dart:core#Map}}$_jsonMapTypeForLiteral';
final _mapEntryType = '{{dart:core#MapEntry}}';
class JsonCodableImplementation
implements ClassDeclarationsMacro, ClassDefinitionsMacro {
@override
MacroDescription get description => MacroDescription(
annotation: QualifiedName(
uri: 'package:_test_macros/json_codable.dart', name: 'JsonCodable'),
runsInPhases: [2, 3]);
@override
Future<AugmentResponse> buildDeclarationsForClass(
Host host, AugmentRequest request) async {
final target = request.target;
return AugmentResponse()
..typeAugmentations![request.target.name] = [
Augmentation(code: expandTemplate('''
// TODO(davidmorgan): see https://github.com/dart-lang/macros/issues/80.
// external ${target.name}.fromJson($_jsonMapType json);
// external $_jsonMapType toJson();
'''))
];
}
@override
Future<AugmentResponse> buildDefinitionsForClass(
Host host, AugmentRequest request) async {
final target = request.target;
final model = await host.query(Query(target: target));
final clazz = model.uris[target.uri]!.scopes[target.name]!;
// TODO(davidmorgan): put `extends` information directly in `Interface`.
final superclassName = MacroScope.current.typeSystem.supertypeOf(target);
return AugmentResponse()
..typeAugmentations![request.target.name] = [
await _generateFromJson(host, model, target, superclassName, clazz),
await _generateToJson(host, model, target, superclassName, clazz)
];
}
Future<Augmentation> _generateFromJson(
Host host,
Model model,
QualifiedName target,
QualifiedName superclassName,
Interface clazz,
) async {
var superclassHasFromJson = false;
// TODO(davidmorgan): add recommended way to check for core types.
if (superclassName.asString != 'dart:core#Object') {
// TODO(davidmorgan): first query could already fetch the super class.
final supermodel = await host.query(Query(target: superclassName));
final superclass =
supermodel.uris[superclassName.uri]!.scopes[superclassName.name]!;
final constructor = superclass.members['fromJson'];
if (constructor != null && _isValidFromJsonConstructor(constructor)) {
superclassHasFromJson = true;
} else {
// TODO(davidmorgan): report as a diagnostic.
throw ArgumentError(
'Serialization of classes that extend other classes is only '
'supported if those classes have a valid '
'`fromJson(Map<String, Object?> json)` constructor.');
}
}
final initializers = <String>[];
for (final field
in clazz.members.entries.where((m) => m.value.properties.isField)) {
final name = field.key;
final type = field.value.returnType;
initializers
.add('$name = ${_convertTypeFromJson("json[r'$name']", type)}');
}
if (superclassHasFromJson) {
initializers.add('super.fromJson(json)');
}
// TODO(davidmorgan): helper for augmenting initializers.
// See: https://github.com/dart-lang/sdk/blob/main/pkg/_macros/lib/src/executor/builder_impls.dart#L500
return Augmentation(code: expandTemplate('''
augment ${target.name}.fromJson($_jsonMapType json) :
${initializers.join(',\n')};
'''));
}
Future<Augmentation> _generateToJson(
Host host,
Model model,
QualifiedName target,
QualifiedName superclassName,
Interface clazz,
) async {
var superclassHasToJson = false;
if (superclassName.asString != 'dart:core#Object') {
// TODO(davidmorgan): first query could already fetch the super class.
final supermodel = await host.query(Query(target: superclassName));
final superclass =
supermodel.uris[superclassName.uri]!.scopes[superclassName.name]!;
final method = superclass.members['toJson'];
if (method != null && _isValidToJsonMethod(method)) {
superclassHasToJson = true;
} else {
// TODO(davidmorgan): report as a diagnostic.
throw ArgumentError(
'Serialization of classes that extend other classes is only '
'supported if those classes have a valid '
'`Map<String, Object?> json toJson()` method.');
}
}
final serializers = <String>[];
for (final field
in clazz.members.entries.where((m) => m.value.properties.isField)) {
final name = field.key;
final type = field.value.returnType;
var serializer = "json[r'$name'] = ${_convertTypeToJson(name, type)};\n";
if (type.type == StaticTypeDescType.nullableTypeDesc) {
serializer = 'if ($name != null) {\n$serializer}\n';
}
serializers.add(serializer);
}
// TODO(davidmorgan): helper for augmenting methods.
// See: https://github.com/dart-lang/sdk/blob/main/pkg/_macros/lib/src/executor/builder_impls.dart#L500
final jsonInitializer =
superclassHasToJson ? 'super.toJson()' : '$_jsonMapTypeForLiteral{}';
return Augmentation(code: expandTemplate('''
augment $_jsonMapType toJson() {
final json = $jsonInitializer;
${serializers.join('')}
return json;
}
'''));
}
/// Returns whether [constructor] is a constructor
/// `fromJson(Map<String, Object?>)`.
bool _isValidFromJsonConstructor(Member constructor) =>
constructor.properties.isConstructor &&
constructor.optionalPositionalParameters.isEmpty &&
constructor.namedParameters.isEmpty &&
constructor.requiredPositionalParameters.length == 1 &&
constructor.requiredPositionalParameters[0].type ==
StaticTypeDescType.namedTypeDesc &&
_isJsonMapType(
constructor.requiredPositionalParameters[0].asNamedTypeDesc);
/// Returns whether [method] is a method
/// `toJson(Map<String, Object?>)`.
bool _isValidToJsonMethod(Member method) =>
method.properties.isMethod &&
!method.properties.isStatic &&
method.requiredPositionalParameters.isEmpty &&
method.optionalPositionalParameters.isEmpty &&
method.namedParameters.isEmpty &&
_isJsonMapType(method.returnType.asNamedTypeDesc);
/// Returns whether [type] is a type `Map<String, Object?>)`.
bool _isJsonMapType(NamedTypeDesc type) =>
type.name.asString == 'dart:core#Map' &&
type.instantiation[0].asNamedTypeDesc.name.asString ==
'dart:core#String' &&
type.instantiation[1].type == StaticTypeDescType.nullableTypeDesc &&
type.instantiation[1].asNullableTypeDesc.inner.asNamedTypeDesc.name
.asString ==
'dart:core#Object';
String _convertTypeFromJson(String reference, StaticTypeDesc type) {
// TODO(davidmorgan): _checkNamedType equivalent.
// TODO(davidmorgan): should this code use `StaticType` and related classes
// instead of using the extension types `StaticTypeDesc` directly?
// TODO(davidmorgan): check for and handle missing type argument(s).
final nullable = type.type == StaticTypeDescType.nullableTypeDesc;
final orNull = nullable ? '?' : '';
final nullCheck = nullable ? '$reference == null ? null : ' : '';
final underlyingType = type.type == StaticTypeDescType.nullableTypeDesc
? type.asNullableTypeDesc.inner
: type;
if (underlyingType.type == StaticTypeDescType.namedTypeDesc) {
final namedType = underlyingType.asNamedTypeDesc;
if (namedType.name.uri == 'dart:core') {
switch (namedType.name.name) {
case 'bool':
case 'String':
case 'int':
case 'double':
case 'num':
return '$reference as ${namedType.name.code}$orNull';
case 'List':
final type = namedType.instantiation.single;
return '$nullCheck [for (final item in $reference '
'as {{dart:core#List}}<{{dart:core#Object}}?>) '
'${_convertTypeFromJson('item', type)}'
']';
case 'Set':
final type = namedType.instantiation.single;
return '$nullCheck {for (final item in $reference '
'as {{dart:core#List}}<{{dart:core#Object}}?>) '
'${_convertTypeFromJson('item', type)}'
'}';
case 'Map':
// TODO(davidmorgan): check for and handle wrong key type.
return '$nullCheck {for (final $_mapEntryType(:key, :value) '
'in ($reference '
'as $_jsonMapType).entries) key: '
'${_convertTypeFromJson('value', namedType.instantiation.last)}'
'}';
}
}
// TODO(davidmorgan): check for fromJson constructor.
return '$nullCheck ${namedType.name.code}.fromJson($reference as '
'$_jsonMapType)';
}
// TODO(davidmorgan): error reporting.
throw UnsupportedError('$type');
}
String _convertTypeToJson(String reference, StaticTypeDesc type) {
// TODO(davidmorgan): add _checkNamedType equivalent.
final nullable = type.type == StaticTypeDescType.nullableTypeDesc;
final nullCheck = nullable ? '$reference == null ? null : ' : '';
final nullCheckedReference = nullable ? '$reference!' : reference;
final underlyingType = type.type == StaticTypeDescType.nullableTypeDesc
? type.asNullableTypeDesc.inner
: type;
if (underlyingType.type == StaticTypeDescType.namedTypeDesc) {
final namedType = underlyingType.asNamedTypeDesc;
if (namedType.name.uri == 'dart:core') {
switch (namedType.name.name) {
case 'bool':
case 'String':
case 'int':
case 'double':
case 'num':
return reference;
case 'List':
case 'Set':
return '$nullCheck [for (final item in $nullCheckedReference) '
'${_convertTypeToJson('item', namedType.instantiation.first)}'
']';
case 'Map':
return '$nullCheck {for (final $_mapEntryType(:key, :value) in '
'$nullCheckedReference.entries) key: '
'${_convertTypeToJson('value', namedType.instantiation.last)}'
'}';
}
}
// TODO(davidmorgan): check for toJson method.
return '$nullCheck $reference.toJson()';
}
// TODO(davidmorgan): error reporting.
throw UnsupportedError('$type');
}
}