blob: 04ad6de66a0292eac6a50fd94bd80756f7ef943b [file] [log] [blame]
// 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
void buildDeclarationsForClass(ClassDeclarationsBuilder builder) {
final name = builder.model.qualifiedNameOf(builder.target.node)!.name;
builder
..declareInType(
Augmentation(
code: expandTemplate('''
// TODO(davidmorgan): see https://github.com/dart-lang/macros/issues/80.
// external $name.fromJson($_jsonMapType json);
'''),
),
)
..declareInType(
Augmentation(
code: expandTemplate('''
// TODO(davidmorgan): see https://github.com/dart-lang/macros/issues/80.
// external $_jsonMapType toJson();
'''),
),
);
}
@override
void buildDefinitionsForClass(ClassDefinitionsBuilder builder) async {
final qualifiedName = builder.model.qualifiedNameOf(builder.target.node)!;
// TODO(davidmorgan): put `extends` information directly in `Interface`.
final superclassName = MacroScope.current.typeSystem.supertypeOf(
qualifiedName,
);
await _generateFromJson(builder, qualifiedName, superclassName);
await _generateToJson(builder, qualifiedName, superclassName);
}
Future<void> _generateFromJson(
InterfaceDefinitionsBuilder builder,
QualifiedName target,
QualifiedName superclassName,
) 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 builder.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 builder.target.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)');
}
builder
.buildConstructor(
builder.model.qualifiedNameOf(
builder.target.members['fromJson']!.node,
)!,
)
.augment(
initializers: [
for (var initializer in initializers)
Augmentation(code: expandTemplate(initializer)),
],
);
}
Future<void> _generateToJson(
InterfaceDefinitionsBuilder builder,
QualifiedName target,
QualifiedName superclassName,
) async {
var superclassHasToJson = false;
if (superclassName.asString != 'dart:core#Object') {
// TODO(davidmorgan): first query could already fetch the super class.
final supermodel = await builder.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 builder.target.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{}';
builder
.buildMethod(
builder.model.qualifiedNameOf(
builder.target.members['toJson']!.node,
)!,
)
.augment(
body: Augmentation(
code: expandTemplate('''
{
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');
}
}