Skip to content
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

Merged
merged 2 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions working/macros/example/bin/checks_main.dart
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 {
Copy link
Member

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?

Copy link
Contributor Author

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.

final String name;
final int age;

Person({required this.name, required this.age});
}
11 changes: 10 additions & 1 deletion working/macros/example/bin/run.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ void main(List<String> args) async {
var jsonSerializableUri =
Uri.parse('package:macro_proposal/json_serializable.dart');
var injectableUri = Uri.parse('package:macro_proposal/injectable.dart');
var checksExtensionsUri =
Uri.parse('package:macro_proposal/checks_extensions.dart');
var bootstrapContent = bootstrapMacroIsolate({
dataClassUri.toString(): {
'AutoConstructor': [''],
Expand All @@ -51,7 +53,11 @@ void main(List<String> args) async {
'Component': [''],
'Injectable': [''],
'Provides': [''],
}
},
checksExtensionsUri.toString(): {
'ChecksExtensions': [''],
'ChecksExtension': [''],
},
}, SerializationMode.byteData);
bootstrapFile.writeAsStringSync(bootstrapContent);
var bootstrapKernelFile =
Expand All @@ -76,6 +82,7 @@ void main(List<String> args) async {
bootstrapKernelFile.path,
'--source=${bootstrapFile.path}',
'--source=lib/auto_dispose.dart',
'--source=lib/checks_extensions.dart',
'--source=lib/data_class.dart',
'--source=lib/functional_widget.dart',
'--source=lib/injectable.dart',
Expand Down Expand Up @@ -119,6 +126,8 @@ void main(List<String> args) async {
'$jsonSerializableUri;${bootstrapKernelFile.path}',
'--precompiled-macro',
'$injectableUri;${bootstrapKernelFile.path}',
'--precompiled-macro',
'$checksExtensionsUri;${bootstrapKernelFile.path}',
'--macro-serialization-mode=bytedata',
'--input-linked',
bootstrapKernelFile.path,
Expand Down
141 changes: 141 additions & 0 deletions working/macros/example/lib/checks_extensions.dart
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 ',
Copy link
Member

Choose a reason for hiding this comment

The 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> {}

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 (@ChecksExtension() here) to actually be visible so it is a little weird. It could actually exist in a src library, so that might be the solution "hiding" it. But it has to be public.

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';
}
2 changes: 2 additions & 0 deletions working/macros/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ environment:
dev_dependencies:
args: ^2.3.0
benchmark_harness: ^2.2.1
checks: ^0.2.0
test: ^1.24.0
dart_style: ^2.2.1
_fe_analyzer_shared: any
frontend_server: any
Expand Down
25 changes: 17 additions & 8 deletions working/macros/feature-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ There are three phases:
### Phase 1: Types

Here, macros contribute new types to the program&mdash;classes, typedefs, enums,
etc. This is the only phase where a macro can introduce a new visible name into
etc. This is the only phase where a macro can introduce a new visible type into
the top level scope.

**Note**: Macro classes _cannot_ be generated in this way, but they can rely on
Expand All @@ -501,13 +501,22 @@ about subtype relations.
### Phase 2: Declarations

In this phase, macros declare functions, variables, and members. "Declaring"
here means specifying the name and type signature, but not the body of a
function or initializer for a variable. In other words, macros in this phase
specify the declarative structure but no imperative code.

When applied to a class, a macro in this phase can introspect on all of the
members of that class and its superclasses, but it cannot introspect on the
members of other types.
here means specifying the name and type signature, but not necessarily the body
of a function or initializer for a variable. It is encouraged to provide a body
(or initializer) if possible, but you can opt to wait until the definition phase
if needed.

When applied to a class, enum, or mixin a macro in this phase can introspect on
all of the members of that class and its superclasses, but it cannot introspect
on the members of other types. For mixins, the `on` type is considered a
superclass and is introspectable. Note that generic type arguments are not
introspectable.

When applied to an extension, a macro in this phase can introspect on all of the
members of the `on` type, as well as its generic type arguments and the bounds
of any generic type parameters for the extension.

TODO: Define the introspection rules for extension types.

### Phase 3: Definitions

Expand Down