-
Notifications
You must be signed in to change notification settings - Fork 214
Minimal viable metaprogramming #4296
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
Comments
Can I insert HTML with this? @[component]
Node counter() {
var count = state<int>(0);
void handleClick() {
count.set(count() + 1);
}
return <button on:click={handleClick}>
Clicked {count()} {count() == 1 ? 'time' : 'times'}
</button>;
} |
Yes, you can insert any text, you just have to know how to parse it. E.g. var ChessGame = @[chess title:"Kasparov vs. Deep Blue"] {
1.Nf3 d5 2.d4 e6 3.g3 c5 4.Bg2 Nc6 5.0-0 Nf6 6.c4 dxc4 7.Ne5 Bd7 8.Na3 cxd4 9.Naxc4 Bc5 10.Qb3 0-0 11.Qxb7 Nxe5 12.Nxe5 Rb8
13.Qf3 Bd6 14.Nc6 Bxc6 15.Qxc6 e5 16.Rb1 Rb6 17.Qa4 Qb8 18.Bg5 Be7 19.b4 Bxb4 20.Bxf6 gxf6 21.Qd7 Qc8 22.Qxa7 Rb8 23.Qa4
// etc
66.Qxf5 Qc6 67.Qf8+ Kc7 68.Qe7+ Kc8 69.Bf5+ Kb8 70.Qd8+ Kb7 71.Qd7+ Qxd7 72.Bxd7 Kc7 73.Bb5 1–0 (Resignation)
} The only requirement is that the block should be syntactically well-formed. Node counter() {
var count = state<int>(0);
void handleClick() {
count.set(count() + 1);
}
return @[component] <button on:click={handleClick}>
Clicked {count()} {count() == 1 ? 'time' : 'times'}
</button>;
} Now, your macro will have to process a pure jsx text. |
Should that be minimal? Consider:
It's a little fidgety since any change in a file containing an annotation can be significant, so if two macros both change the same file, they'll keep getting invoked until they both stop editing. It modifies the original file directly, and has direct access to the file system. (Probably have to restrict it to the Pub directory somehow). That's obviously dangerous, but also maximally powerful. |
I'm not sure it's "more with less". In my mental model, no rescan of the entire file is necessary. // other code
@[some_macro]
class Foo {
// somewhere
@[another_macro] var x = something;
}
// other code The preprocessor detects level-0 invocation ( @[some_macro] {
library foo;
//The entire library gets preprocessed
} but this scenario is not a typical one. Important: by construction, the macro cannot change anything "in the file". It doesn't know what "file" is. It receives text as input and returns text as output. Footnotes
|
The lack of access to type information may at first glance look like a major scandal. But in reality, a good argument can be made that type info is immaterial. |
More importantly, the lack of type information means we can't generate copyWith/==/toString/... ; since we don't have the list of fields nor their types. |
@[generate_to_string]
class A {
int a;
String b;
// etc.
} @rrousselGit: I don't understand your argument. The whole block starting with "class A" gets passed into the macro. Macro parses it with a standard dart parser and finds out what the fields/types are. But only the class A is available for parsing! If the class contains a field of type B, then the generator can call it via |
perhaps to reduce complexity and still maintain a minimal type analysis, it could be restricted to an analysis based on a simple parse implementation that analyzes only the file where the macro is applied and only analyzes primitive types and ignores inheritance, which is the most complex part in my opinion. Even with this restriction, it can still be very useful for simple types that do not use inheritance. |
Take Freezed Unions: @freezed
class Union {
factory Union.a(int value) = A;
factory Union.b(double? value) = B;
} This generates the field: num? value; so that folks can do: Union union = ...;
num? value= union.value; // legal
if (union is A) {
int value2 = union.value; // legal too.
} This requires type information to know that the shared interface between all |
@rrousselGit: this argument shows that the union should be a first-class construct, I'm really interested in other examples. I'd like to find out the limits of the concept of a minimal viable thing. |
Freezed again. Deep copy: @freezed
class A {
A(this.b);
final B b;
}
@freezed
class B {
B(this.value);
final C c;
}
@freezed
class C {
C(this.value);
final int value;
} This supports: A a;
a.copyWith.b.c(value: 42); It relies on knowing if a field is using Freezed or not. That requires inspecting the field. Mobx class Store {
@observable
int value = 0;
@observable
List<int> list = 0;
} This encapsulates the getters/setters into a different This requires knowing the type of a field. Rivepod @riverpod
Future<int> async(ref) => ...;
@riverpod
Stream<int> stream((ref) => ...; This generates an object of a different type based on wether the return value is a Future/Stream or a random object. functional_wdiget @sWidget()
Widget home(BuildContext context, {int? id}) => ... This generates a class ; which happens to override I could list more. Type information is quite critical to codegen. |
@rrousselGit: class Store {
@observable
int value = 0;
@observable
List<int> list = 0;
}
But... the macro for Please send more examples! I have to categorize them! |
Type annotations != type Consider: typedef MyList = List<int>; or:
Or
Those are ways to define lists that no amount of I'm not saying you can't generate some code with just the AST. I've certainly attempted to do so in some of my generators, for the sake of speed. You'll have to cut some features ,and your users will regularly have to debug "why is the generated code failing" because you can't have proper error handling. It wouldn't meet Dart's standard of user-friendlyness and quality IMO. |
You listed corner cases which are either never or very rarely occur in the code subject to metaprogramming. @freezed
class B {
B(this.value);
@freezed final C c;
// OR
final D d @freezed;
} In any case, you need some stats to determine whether theoretical possibilities are real, or else you will be optimizing for a non-existent case.
No, you don't have to do it. You just parse it with a standard dart parser, it may generate AST or other TBD format. You can go through all your examples and verify that an extra annotation (whenever necessary) can help. There are 2 forms: extra parameter in the macro call
I am not aware of any current or currently discussed approaches to metaprogramming that could qualify as "meeting high Dart's standards". If a 100K LOC project can be preprocessed within 1 sec 1, it's a big step forward. Big enough to justify some rare inconveniences (I'm not even sure there are many). That's not all. The user will be able to write the code with no artifacts like extending Footnotes
|
Does |
Not sure this answers your question, but here's what ChatGpt suggests: The package:analyzer provides a way to parse individual expressions without needing an entire Dart file. You can use the parseString() function along with the Parser.parseExpression() method to parse standalone expressions. Here’s how you can do it: import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
void main() {
var expression = 'a + b * c';
var result = parseString(content: expression, throwIfDiagnostics: false);
var expressionNode = result.unit.childEntities.firstWhere(
(node) => node is Expression,
orElse: () => null,
);
if (expressionNode is Expression) {
print('Parsed Expression: ${expressionNode.toSource()}');
} else {
print('Failed to parse expression.');
}
} You can go from here. Pretty sure analyzer provides all building blocks out of the box. Use chatGpt or any other AI - it may generate some code. |
@lrhn: I want to correct myself. If the macro generates the code where it inserts other macro calls (via |
What if generators accepted the analysis result for the file only? ie, that just that compilation unit (if I understand it right) then, we also say that generators are not allowed to shadow types used in that file. this way, the only usage of generated types, are those elements that haven't been resolved in the first place. @freezed
sealed class MyUnion {
// not allowed. if the generator tries to generate it, you get an ambiguity error, and you'll be forced to use a prefix
factory MyUnion.intBad(int i) = int;
// generator sees the prefix and can specify a library self-import of some kind to allow the prefix.
// may require additional language feature, or just error I guess.
factory MyUnion.intBetter(int i) = g.int;
factory MyUnion.int(int i) = UInt;
factory MyUnion.double(double i) = UDouble;
}
@freezed
class Wrapper {
const Wrapper(this.union);
final MyUnion union;
}
// strawman.
// specifying the type will automatically apply meta-meta annotation to allow only for classes.
// otherwise use the annotation and accept Element, or something.
// probably need some kind of general resolver.
// thisLibrary as a context for reachability and prefixes.
freezed(Library thisLibrary, ClassElement annotatedElement, StringSink writer) {
final factories = annotatedElement.constructors.whereType<FactoryConstructor>();
for (final factory in factories) {
// or whatever
final InterfaceElement type = factory.redirected;
assert(type.type is InvalidType);
// or
if (type.isResolved) {
reportError(..);
continue;
}
// is able to check parameter.type. is a freezed class? that's okay, we're not generating copyWith yet, just the interface. that will be a nested macro so that the interfaces we need will be available then.
_generateFreezedClass(thisLibrary, classElement, type.name, writer);
}
}
// gen?
augment MyUnion {
num get i;
// == and hashcode
@freezedCopyWith
MyUnionCopyWith get copyWith;
}
abstract mixin class MyUnionCopyWith {
// some factory constructor that may be marked external or references an unresolved Impl type
MyUnion call({int? i});
}
class UInt implements MyUnion {...}
...
augment class Wrapper {
// normal stuff
@freezedCopyWith
WrapperCopyWith get copyWith;
}
// some implementation details idk.
abstract mixin class WrapperCopyWith {
// some factory constructor that may be marked external or references an unresolved Impl type
Wrapper call({MyUnion? union});
UnionCopyWith get union;
}
// level 2
// the actual copyWith classes, and augmentations that introduce them. basically, normal code gen, but with some restrictions so we don't end up cycling or with shadowed types rendering the generated code out of date |
The fact of the matter is, we need type information. its not possible to write good, reliable generators without it. we want users to be able to use normal dart constructs like aliases, prefixes, etc, and we simply wont be able to properly handle it otherwise. it would be impossible to support multiple types of patterns otherwise, such as dart_mappable's fromMap or generic argument factories for serialization, or maybe being able to choose between technically we could ask for that information in the annotation, but... why? the information is there, lets get it |
Where "there"? "There" is a function of time. E.g. a macro handling class A wants to know if class B implements the method (note that in the last model, you could ask the same question: why can't I know the values of constant expressions? The constants are there after all. But... they are not frozen in time!) The best way to deal with Gordian Knots like this is to cut them. That's what I'm trying to propose here. I'd like to emphasize one important point of the proposal. If the macro makes some assumptions about the types based on annotations, heuristics etc, it has to insert "assert" statements that verify the assumptions statically. That guarantees that no possible misconception would propagate to the runtime. Another characteristic of the proposed mechanism: preprocessing can be easily parallelized (b/c nobody depends on anybody else). This is a minor point considering that the preprocessing would be very fast as it is, but is worth pointing out for completeness. |
if a class doesnt have a do it in stages.
It would probably take some doing to find a best practice for that, but i remember that one of the big problems for me with macros was that i couldnt check a type and also define a new one at the same stage, as what i define depended on the type. with this, it would work fine. perhaps a macro could indicate that its environment isnt ready yet, and to please call it again next stage, where the desired generated code may be available? macro application is complete only when all macros have resolved (or remaining macros are all compile-time implementation generators, which would have compile-checked grantees that it does not add anything to any interface) i figure if a macro isnt done and theres nothing else to run, then its not executed again and a diagnostic error is shown on the macro or maybe a macro marks itself as complete instead, like either one probably returns |
Build_runner will get better on that area apparently. With augments, there's plan to support augmentations to add other augmentations. So you could have an infinite number of "stages" |
My internal "complexity indicator" flashes red :-) |
I doubt it. just put a cap on how many passes we're allowed to make. devs shouldn't have recursive macros, or macros that never finish working. that will push for better implementations. the only reason you need more than one pass in the first place is to depend on generated code. how many new types/interface modifications can macros need? |
In principle, the model can be extended so that the macros actually have access to type information via something like reflectable. The type information can be collected and cached based on (the type info should, among other things, contain the annotations of both forms |
I think the approach outlined in the previous comment is feasible as a community project. At least, it's a much easier effort than dart-rust interface. |
Uh oh!
There was an error while loading. Please reload this page.
TL;DR: treating a macro as a pure function transforming input text into output text, replacing the original.
I've been trying to identify the minimal set of features that enable code generation in most known use cases.
I'm thinking of the following setup.
out.write(String text)
.f(g(x))
, we expectg
to be called first - the same is true with macros.@[some_macro a:'hello', b: 42]
The challenging part is how to syntactically identify the boundaries of the block to be passed to the macro.
The conjecture is that 2 rules can cover most of the use cases:
The code gets scanned till the first occurrence of
;
or{
, whatever comes first.If it's
;
the block ends at that point.Otherwise, the code gets scanned till the matching occurrence of
}
.The text was updated successfully, but these errors were encountered: