-
Notifications
You must be signed in to change notification settings - Fork 205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add basic checks extension macro #3315
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
@ChecksExtensions([Person]) | ||
library; | ||
|
||
import 'package:checks/checks.dart'; | ||
import 'package:test/test.dart'; | ||
|
||
import 'package:macro_proposal/checks_extensions.dart'; | ||
|
||
void main() { | ||
test('can use generated extensions', () { | ||
final draco = Person(name: 'Draco', age: 39); | ||
check(draco) | ||
..name.equals('Draco') | ||
..age.equals(39); | ||
}); | ||
} | ||
|
||
class Person { | ||
final String name; | ||
final int age; | ||
|
||
Person({required this.name, required this.age}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
// 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:async'; | ||
|
||
// There is no public API exposed yet, the in-progress API lives here. | ||
import 'package:_fe_analyzer_shared/src/macros/api.dart'; | ||
|
||
import 'util.dart'; | ||
|
||
/// Generates extensions for the `checks` package for a list of types. | ||
/// | ||
/// The extensions will be "on" the type `Subject<SomeType>`, for each given | ||
/// type. | ||
/// | ||
/// Each extension will have a getter for each field in the type it is | ||
/// targetting, of the form `Subject<SomeFieldType> | ||
macro class ChecksExtensions implements LibraryTypesMacro { | ||
final List<NamedTypeAnnotation> types; | ||
|
||
const ChecksExtensions(this.types); | ||
|
||
@override | ||
Future<void> buildTypesForLibrary( | ||
Library library, TypeBuilder builder) async { | ||
// ignore: deprecated_member_use | ||
final subject = await builder.resolveIdentifier( | ||
Uri.parse('package:checks/checks.dart'), 'Subject'); | ||
// ignore: deprecated_member_use | ||
final checksExtension = await builder.resolveIdentifier( | ||
Uri.parse('package:macro_proposal/checks_extensions.dart'), | ||
'ChecksExtension'); | ||
for (final type in types) { | ||
if (type.typeArguments.isNotEmpty) { | ||
throw StateError('Cannot generate checks extensions for types with ' | ||
'explicit generics'); | ||
} | ||
final name = '${type.identifier.name}Checks'; | ||
builder.declareType( | ||
name, | ||
DeclarationCode.fromParts([ | ||
'@', | ||
checksExtension, | ||
'()', | ||
'extension $name on ', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is interesting - it looks like the macro runs in two steps? Does that mean that folks who want more customization can do something like @ChecksExtension()
extension CustomName on Subject<SomeType> {} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, it wouldn't have to work this way but that is imo the best way to write it. It would have to run in two phases regardless. The macro could be smart enough to skip adding extensions that already exist though, so you could write custom ones and have it fill in the rest. I am not sure all other macro use cases would want the 2nd macro ( |
||
NamedTypeAnnotationCode(name: subject, typeArguments: [type.code]), | ||
'{}', | ||
])); | ||
} | ||
} | ||
} | ||
|
||
/// Adds getters to an extension on a `Subject` type which abstract away the | ||
/// `has` calls for all the fields of the subject. | ||
macro class ChecksExtension implements ExtensionDeclarationsMacro { | ||
const ChecksExtension(); | ||
|
||
Future<void> buildDeclarationsForExtension( | ||
ExtensionDeclaration extension, MemberDeclarationBuilder builder) async { | ||
// ignore: deprecated_member_use | ||
final subject = await builder.resolveIdentifier( | ||
Uri.parse('package:checks/checks.dart'), 'Subject'); | ||
final onType = extension.onType; | ||
if (onType is! NamedTypeAnnotation || | ||
onType.identifier != subject || | ||
onType.typeArguments.length != 1) { | ||
throw StateError( | ||
'The `on` type must be a Subject with an explicit type argument.'); | ||
} | ||
|
||
// Find the real named type declaration for our on type, and ensure its a | ||
// real named type (ie: not a function or record type, etc); | ||
final onTypeDeclaration = await _namedTypeDeclarationOrThrow( | ||
onType.typeArguments.single, builder); | ||
|
||
// Ensure that our `on` type is coming from a null safe library, we don't | ||
// support legacy code. | ||
switch (onTypeDeclaration.library.languageVersion) { | ||
case LanguageVersion(:int major) when major < 2: | ||
case LanguageVersion(major: 2, :int minor) when minor < 12: | ||
throw InvalidCheckExtensions('must be imported in a null safe library'); | ||
} | ||
|
||
// Generate the getters | ||
final fields = | ||
await builder.fieldsOf(onTypeDeclaration as IntrospectableType); | ||
for (final field in fields) { | ||
if (_isCheckableField(field)) | ||
await _declareHasGetter(field, builder, subject); | ||
} | ||
} | ||
|
||
/// Find the named type declaration for [type], or throw if it doesn't refer | ||
/// to a named type. | ||
/// | ||
/// Type aliases are followed to their underlying types. | ||
Future<TypeDeclaration> _namedTypeDeclarationOrThrow( | ||
TypeAnnotation type, DeclarationBuilder builder) async { | ||
if (type is! NamedTypeAnnotation) { | ||
throw StateError('Got a non interface type: ${type.code.debugString()}'); | ||
} | ||
var onTypeDeclaration = await builder.typeDeclarationOf(type.identifier); | ||
while (onTypeDeclaration is TypeAliasDeclaration) { | ||
final aliasedTypeAnnotation = onTypeDeclaration.aliasedType; | ||
if (aliasedTypeAnnotation is! NamedTypeAnnotation) { | ||
throw StateError( | ||
'Got a non interface type: ${type.code.debugString()}'); | ||
} | ||
onTypeDeclaration = | ||
await (builder.typeDeclarationOf(aliasedTypeAnnotation.identifier)); | ||
} | ||
return onTypeDeclaration; | ||
} | ||
|
||
/// Declares a getter for [field] that is a convenience method for calling | ||
/// `has` and extracting out the field. | ||
Future<void> _declareHasGetter(FieldDeclaration field, | ||
MemberDeclarationBuilder builder, Identifier subject) async { | ||
final name = field.identifier.name; | ||
builder.declareInType(DeclarationCode.fromParts([ | ||
NamedTypeAnnotationCode(name: subject, typeArguments: [field.type.code]), | ||
// TODO: Use an identifier for `has`? It exists on `this` so it isn't | ||
// strictly necessary, this should always work. | ||
'get $name => has(', | ||
'(v) => v.$name,', | ||
"'$name'", | ||
');', | ||
])); | ||
} | ||
|
||
bool _isCheckableField(FieldDeclaration field) => | ||
field.identifier.name != 'hashCode' && !field.isStatic; | ||
} | ||
|
||
class InvalidCheckExtensions extends Error { | ||
final String message; | ||
InvalidCheckExtensions(this.message); | ||
@override | ||
String toString() => 'Invalid `CheckExtensions` annotation: $message'; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we make an example where the class we generate for is imported?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't make any difference in this case - and the boilerplate required right now for running these is already a lot, I don't really want to add more files.
For the real macro, once you can run it in a more sane way, you could absolutely do that.