@macroforge/mcp-server
Version:
MCP server for Macroforge documentation and code analysis
135 lines (104 loc) • 6.84 kB
Markdown
# Type-Aware Macros
Before expanding, Macroforge scans the project and records every class, interface, enum and type
alias it finds. A macro can look any of them up, so it can generate code that depends on what a
field's type really is, not just on its name.
## The Type Registry
The context's `type_registry` holds the scan. Each entry is a type's IR, where it is declared and
the imports of its file:
Rust
```
pub struct TypeRegistryEntry {
pub name: String, // "User"
pub file_path: String, // where it is declared
pub is_exported: bool,
pub definition: TypeDefinitionIR, // Class, Interface, Enum or TypeAlias IR
pub file_imports: Vec<FileImportEntry>, // that file's imports
}
```
### Looking Types Up
| Method | Finds |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `resolve_in_file(name, file, imports)` | The type `name` means in `file`: what it imports under that name, or what it declares. Use this one for a name read from a field. |
| `resolve(name, imports)` | The same, without the same-file check |
| `get(name)` | The type by name alone; `None` when several files declare that name |
| `get_all(name)` | Every declaration with that name |
| `get_qualified("src/user.ts::User")` | A declaration in a given file |
| `alias_cycle(name)` | The loop, if following `name`'s aliases leads back to it |
Rust
```
use macroforge_ts::ts_syn::TypeDefinitionIR;
let ctx = &input.context;
let imports = ctx.import_registry.file_import_entries();
let resolved_fields = ctx.resolved_fields.as_ref();
for field in class.fields() {
// The field's type with arrays and generics stripped: "User" for "User[]"
let Some(resolved) = resolved_fields.and_then(|fields| fields.get(&field.name)) else {
continue;
};
let Some(entry) =
ctx.type_registry.resolve_in_file(&resolved.base_type_name, &ctx.file_name, &imports)
else {
continue; // a primitive, a generic parameter, or a type outside the project
};
if let TypeDefinitionIR::Enum(enum_ir) = &entry.definition {
// the field holds one of enum_ir.variants
}
}
```
Note
Macroforge records every lookup a macro makes, so when a looked-up type changes, the files whose
expansion depended on it are expanded again. Look types up through the registry rather than reading
other files yourself, or a change will not reach your macro's output.
### Resolved Field Types
For a derive on a class or interface, `resolved_fields` has every field's type already followed,
keyed by field name:
Rust
```
pub struct ResolvedTypeRef {
pub raw_type: String, // "User[]"
pub base_type_name: String, // "User"
pub registry_key: Option<String>, // the registry entry, when it is a project type
pub is_collection: bool, // an array, Set, Map or the like
pub is_optional: bool, // | null or | undefined
pub type_args: Vec<ResolvedTypeRef>, // Map<string, User> → [string, User]
}
```
Rust
```
use macroforge_ts::builtin::derive::common::resolved_type_has_derive;
if let Some(resolved) = input.context.resolved_fields.as_ref().and_then(|f| f.get(&field.name)) {
// Does the field's type, or its element type, also derive Clone?
if resolved_type_has_derive(&input.context.type_registry, resolved, "Clone") {
// call the type's generated clone helper instead of copying
}
}
```
### Generic Aliases
`resolve_generic_aliases` expands every generic alias in a type string, so a macro sees the shape
behind it. With `type Link<T> = string | T`, `Link<User>[]` becomes `(string | User)[]`:
Rust
```
use macroforge_ts::ts_syn::resolve_generic_aliases;
let shape = resolve_generic_aliases(
&field.ts_type,
&ctx.type_registry,
&ctx.file_name,
&ctx.import_registry.file_import_entries(),
);
```
### Helpers
`macroforge_ts::builtin::derive::common` holds the helpers the built-in macros use:
| Helper | Use |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `type_has_derive(registry, name, "Clone")` | Whether a project type derives a macro |
| `resolved_type_has_derive(registry, resolved, "Clone")` | The same, for a field's resolved type |
| `collection_element_type(resolved)` | The element of `User[]`, a `Set`'s value, a `Map`'s value |
| `standalone_fn_name("User", "Clone")` | The name of a generated helper: `userClone` |
| `get_effective_fields(alias, registry)` | The fields of an object alias, or of an intersection of them |
| `is_primitive_type`, `is_numeric_type`, `is_nullable_type`, `is_generic_type` | Classify a type string |
| `get_type_default`, `get_type_default_with_registry` | A default value expression for a type |
## Importing a Type's Helpers
Code generated for one type often calls helpers generated beside another, in another module.
`ctx.import_specifier_for("User")` gives the module the current file should import from: the one it
already imports `User` from, or else the relative path to `User`'s file. The stream methods in
[Output and Imports](../../docs/custom-macros/output#imports) build on it.