[ffigen] Vibe coded prototype of transformer API - #3482
Conversation
PR HealthChangelog Entry ❗
Changes to files need to be accounted for in their respective changelogs. This check can be disabled by tagging the PR with API leaks ✔️The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.
This check can be disabled by tagging the PR with
Breaking changes
|
| Package | Change | Current Version | New Version | Needed Version | Looking good? |
|---|---|---|---|---|---|
| jni | Breaking | 1.0.3 | 1.0.3 | 2.0.0 Got "1.0.3" expected >= "2.0.0" (breaking changes) |
|
| code_assets | Non-Breaking | 1.2.1 | 1.3.0-wip | 1.3.0-wip | ✔️ |
| objective_c | Breaking | 9.5.0 | 9.5.0 | 10.0.0 Got "9.5.0" expected >= "10.0.0" (breaking changes) |
|
| hooks_runner | Non-Breaking | 1.6.1 | 1.6.2-wip | 1.6.2-wip | ✔️ |
This check can be disabled by tagging the PR with skip-breaking-check.
dcharkes
left a comment
There was a problem hiding this comment.
🙌 😄
To continue supporting the old callback system, we construct a visitor from those callbacks, and run it before the user's visitors. Probably should deprecate the callbacks though.
For the vibe coded solution, it would be useful to remove the fields to see what it looks like. I saw at least 1 field which needs to find a new home when we do so. (For the actual change we can use deprecated and document where it should be configured instead.)
Some high level questions:
I don't see any migration of any real world FFIgen config. So it's a bit hard to imagine what the impact of this is for users on the Dart API or the yaml config currently. Could we add some larger migrations to the PR? Do we have any real-world configs in this repo or would we need to look at some user repos? E.g. cupertino_http etc.
Should we consider not providing IncludeSetVisitor? E.g. how much boilerplate is it to write this yourself? My thinking is that if you want something that you don't have a predefined visitor for, you'll need to write a custom one yourself, at which point you might as well fold the predefined sets into your custom one?
What do you envision as the typical user-journey for complicated configurations:
- A long list of visitors all doing one aspect.
- A single custom visitor?
Which of those two options is more readable? Can you look at some complicated existing configurations and sketch out both options? Is there a clear winner?
(If a long list of visitors is the API winner, are we concerned about performance? If that's the case would we want to mix in visitor aspects into a single visitor for performance reasons?)
| // TODO: Add a code snippet example. | ||
| final class FfiGenerator { | ||
| /// User custom visitors to modify/filter AST elements. | ||
| final List<Visitor>? visitors; |
There was a problem hiding this comment.
In which order do these run?
There was a problem hiding this comment.
One at a time, first to last.
There was a problem hiding this comment.
Let's add that to the doc comment :)
| /// headers. | ||
| // TODO: Add a code snippet example. | ||
| final class FfiGenerator { | ||
| /// User custom visitors to modify/filter AST elements. |
There was a problem hiding this comment.
All the other fields are called "configuration".
Maybe we should say something along the lines of:
/// Visitors to configure the generation options for target language declarations.
///
/// You can filter:
/// code snippet
///
/// You can rename:
/// code snippet
///
/// The visitors run in order.
/// some explanation or exampleThere was a problem hiding this comment.
We need to get rid of all of this. Could you vibecode the removal of all those fields?
There was a problem hiding this comment.
Sure. Running it now.
| }), | ||
| RecordUseVisitor(), | ||
| ], | ||
| enums: const Enums(silenceWarning: true), |
There was a problem hiding this comment.
Hm, we need to think where to put everything that doesn't end up in visitors that fit next to the declaration-type-specific config before. How many of such elements are there?
There was a problem hiding this comment.
WDYM? I figured we'd just leave those fields where they are, in the hierarchy. Are you suggesting we delete the whole existing hierarchy?
I had it working on migrating some of our configs. I'll upload when it's ready.
I thought of it as not so much a boilerplate saving as something to ease migration. But as you pointed out, it means that we have to lump all the includes into one set (or else it gets to be a pretty messy API with different sets per type). I don't have a strong opinion either way. Maybe it would be better to ease migration by just having good examples in the documentation.
I expect most users could get by with one visitor. More complicated workflows (like our internal visitors) may need to use several visitors. Other users may choose to split up their visitors for readability, eg one visitor per high level task. I don't think performance is a significant issue, so long as users aren't for example creating one visitor per element they want to filter. I don't think even power users would need more than a handful of visitors. |
|
Migrated a bunch more monorepo configs, as well as cupertino_http and sqlite3. |
|
|
||
| final class Enums { | ||
| /// Whether to silence warning for enum integer type mimicking. | ||
| final bool silenceWarning; |
There was a problem hiding this comment.
Not much point having nested classes with only one field. Should flatten this.
| } | ||
|
|
||
| /// Configuration for C++. | ||
| final class Cpp { |
There was a problem hiding this comment.
This is now just a sentinel class. The Config.cpp field is essentially a bool. I'd probably keep this around though, as it's quite likely that we'll need to add C++ specific options again.
| /// the [Declarations], but are transitively included by other bindings, | ||
| /// will be code-genned as if they were included. If disabled, these | ||
| /// transitively included interfaces will be generated as stubs instead. | ||
| final bool includeTransitive; |
There was a problem hiding this comment.
This is a leftover from the YAML config. Might be worth removing (always false). This would force users to explicitly include all the things they care about.
There was a problem hiding this comment.
Same for Protocols.includeTransitive.
Categories.includeTransitive could probably also be deleted, but it defaults to true. I think we'd want to keep it that way? The main issue is that there are a lot of undocumented categories in ObjC APIs that hold extension methods of classes which the official documentation treats as ordinary methods on that class. So it's super confusing for users when those methods just don't show up in the generated bindings.
There was a problem hiding this comment.
Hm interesting, so now that we visit individual objects, we don't have a place to stick global config w.r.t. all objects of that kind.
We could also put something on visitors to put it closer, but then it becomes mutable state for all the global config, which is also a bit weird. I think in general it's weird to have config on global state.
Should we consider moving the global options to be per definition?
e.g. silence the warning per enum.
And for including categories a setter on Class bool get includeCategories => true.
or on a Class a getter categories so that you can loop over them and include them.
Ditto for the other transitive things, can we navigate the the kinds hierarchy so that you could do transitivity by just walking the graph of definitions?
There was a problem hiding this comment.
Doing transitivity that way isn't possible without exposing more of the Types, which is a much bigger change. For example, you don't have access to the arg/return types of any of the methods.
There was a problem hiding this comment.
Yeah lets remove the include transitives for now.
And the categories we don't actually need transitive right? It's just one step from the classes. So having a setter on class is easy. I agree that it should default to true. (It would also be annoying if in Dart you would not generate extension methods on classes by default.)
And for the enum int warning, can we do that as a setter on the enum?
Do we then have any of the kinds left?
dcharkes
left a comment
There was a problem hiding this comment.
Thanks @liamappelbe having the sample migrations is really useful! 🙏
Some high level thoughts:
-
I think what I liked about the yaml config and the previous config with the callbacks is that it facilitates configuration being in a predictable place in one physical location in the code. You build one (giant) object literal, and you can exactly know where inside that literal you can find some code.
- I think one way to get back to that is anonymous classes that implement the visitor. But we don't have those in Dart. The next best thing is a constructor that takes a named param callback for each method that's overridable.
- Secondly, all const sets of included names, and const maps for renames should be inlined in the visit functions where they are used. That way they are in the most narrow context. You don't have to scroll around in the code.
-
I'd love to be able to get rid of all previous
Enums,Functionsconfig etc. Otherwise we'd have configuration in two places (1) in the visitor pertaining to one definition at the time (2) in those global fields pertaining to all objects at the time. Can we express all use cases by adding properties to single definitions getting rid of all the global ones that apply to all?- What about those transitives?
| import 'wrapper_generators/logging.dart'; | ||
|
|
||
| class JniVisitor extends ffigen.Visitor { | ||
| static const enumRenames = { |
There was a problem hiding this comment.
I think I'd inline the const sets that are only used in one method in the method. So that they are more scoped to a narrow context.
|
|
||
| class ObjCBindingsVisitor extends Visitor { | ||
| static const interfaces = { | ||
| 'DOBJCDartInputStreamAdapter': 'DartInputStreamAdapter', |
There was a problem hiding this comment.
Could this be expressed more succinctly instead of having a map where a bunch of keys are the same as the values?
| } | ||
| } | ||
|
|
||
| List<String> writeBuiltInTypes(String out, String bindingsFile) { |
There was a problem hiding this comment.
This could do with some documentation I think.
| generateForPackageObjectiveC: true, | ||
| categories: Categories(includeTransitive: false), | ||
| ), | ||
| visitors: [const ObjCBindingsVisitor()], |
There was a problem hiding this comment.
I'd kinda want an anonymous class (dart-lang/language#2927), so that the visitor is inlined here. That way we keep the configuration as a single big object literal.
We could achieve that if we provide some kind of base class where all methods are callbacks provided in the constructor, but then they don't have access to a this. But I guess this doesn't have any useful info typically anyways.
FfiGenerator(
headers: Headers(
entryPoints: [
root.resolve('src/foundation.h'),
root.resolve('src/input_stream_adapter.h'),
root.resolve('src/ns_number.h'),
root.resolve('src/observer.h'),
root.resolve('src/protocol.h'),
],
),
visitors: [
Visitor(
function: (Func node) {
node.isExcluded = true;
},
objcInterface: (ObjCInterface node) {
const interfaces = {
'DOBJCDartInputStreamAdapter': 'DartInputStreamAdapter',
// ..
};
final renamed = interfaces[node.originalName];
if (renamed != null) {
node.isExcluded = false;
node.name = renamed;
}
if (node.originalName == 'NSBundle') {
for (final method in node.methods) {
if (method.originalName ==
'localizedStringForKey:value:table:localizations:') {
method.isExcluded = true;
}
}
}
}
),
],We could make the Visitor constructor a forwarding factory that forwards to an impl that has callbacks for each method.
@goderbauer WDYT about the design goal to be able to to nest everything inside the FfiGenerator as an object literal?
There was a problem hiding this comment.
Looking at the package:cuppertino_http and package:squlite3 examples I think this is a good design goal.
| ), | ||
| objectiveC: const ObjectiveC(), | ||
| visitors: const [ | ||
| IncludeSetVisitor({'AVAudioPlayer'}), |
There was a problem hiding this comment.
One information that's lost now is what kind of thing we are including here. In the old code you could see whether we are retaining an interface, a function, a whatever. Should we keep that information around in the API?
There was a problem hiding this comment.
I left a similar comment earlier, I'd either want no include set visitor at all. Or something like
IncludeSetVisitor(functions: {'AVAudioPlayer'}, structs: ...),| // TODO: Add a code snippet example. | ||
| final class FfiGenerator { | ||
| /// User custom visitors to modify/filter AST elements. | ||
| final List<Visitor>? visitors; |
There was a problem hiding this comment.
Let's add that to the doc comment :)
| // TODO: Add a code snippet example. | ||
| final class FfiGenerator { | ||
| /// User custom visitors to modify/filter AST elements. | ||
| final List<Visitor>? visitors; |
There was a problem hiding this comment.
Is there a difference between setting this to null and setting it to []? If not, I would make this non-nullable and use [] to represent the "no visitor set" state. If there is a difference, let document.
| bool get isExcluded; | ||
| set isExcluded(bool value); |
There was a problem hiding this comment.
Left this comment elsewhere, but this api with double negatives like node.isExcluded = false reads a little awkward and makes it harder to reason about code (e.g. something like node.isExcluded = !_structNames.contains(node.originalName);). Could we turn this API around (isIncluded) or is there a strong reason for having it this way around?
There was a problem hiding this comment.
Mainly historical, for consistency with JNIgen. But we could switch both to isIncluded, and in JNIgen keep around (deprecated?) isExcluded getter/setters that delegate to isIncluded for backwards compatibility.
As far as I can tell, |
|
Uploaded more API changes. Gemini is currently addressing some of the general feedback about the way that existing configs were migrated. Might take a while |
Package publishingIf you have publishing permissions, you can use the links below to publish the changes after merging this PR.
Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. |
|
I'm pretty happy with how the new API looks. I'm gonna start splitting this PR up into more manageable chunks. |
|
This is the proposed JNIgen API, where I've attempted to align it with the FFIgen one: https://gist.github.com/liamappelbe/d6b70114fb835a12d367cac3ad879ae7 |
I'd need to browse the migrated invocations rather than the API to get a feel. |
Following the design of JNIgen's transformers, add a public facing AST that wraps the internal AST and exposes a subset of the fields, then provide a visitor pattern that the user can use to mutate that AST in limited ways.
Collaborated with Gemini on a design, then let it do the migration. I haven't yet done a full review of its code, so this is not ready to land. Let's just see if we like the approach first.
Notes
Types are not exposed at all. I could see them being useful as an additional way to identify methods, but it would be complicated. If a user has a concrete use case for it, then we'll design a solution based on that use case.