Skip to content
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
2 changes: 2 additions & 0 deletions codama-nodes/src/generated/value_nodes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod string_value_node;
mod struct_field_value_node;
mod struct_value_node;
mod tuple_value_node;
mod value_node;

pub use array_value_node::*;
pub use boolean_value_node::*;
Expand All @@ -33,3 +34,4 @@ pub use string_value_node::*;
pub use struct_field_value_node::*;
pub use struct_value_node::*;
pub use tuple_value_node::*;
pub use value_node::*;
31 changes: 31 additions & 0 deletions codama-nodes/src/generated/value_nodes/value_node.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use crate::{
ArrayValueNode, BooleanValueNode, BytesValueNode, ConstantValueNode, EnumValueNode, HasKind,
MapEntryValueNode, MapValueNode, NoneValueNode, NumberValueNode, PublicKeyValueNode,
SetValueNode, SomeValueNode, StringValueNode, StructFieldValueNode, StructValueNode,
TupleValueNode,
};
use codama_nodes_derive::{node_union, RegisteredNodes};

#[derive(RegisteredNodes)]
#[node_union]
pub enum RegisteredValueNode {
Array(ArrayValueNode),
Boolean(BooleanValueNode),
Bytes(BytesValueNode),
Constant(ConstantValueNode),
Enum(EnumValueNode),
Map(MapValueNode),
None(NoneValueNode),
Number(NumberValueNode),
PublicKey(PublicKeyValueNode),
Set(SetValueNode),
Some(SomeValueNode),
String(StringValueNode),
Struct(StructValueNode),
Tuple(TupleValueNode),

#[registered]
MapEntry(MapEntryValueNode),
#[registered]
StructField(StructFieldValueNode),
}
6 changes: 0 additions & 6 deletions codama-nodes/src/value_nodes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,6 @@ mod struct_value_node;
mod tuple_value_node;
mod value_node;

// `value_node.rs` hosts the hand-written `RegisteredValueNode` (with
// the `#[derive(RegisteredNodes)]` registered/standalone split) which
// can't be reproduced mechanically — re-export it so the category
// union stays reachable at the crate root.
pub use value_node::*;

// `number_value_node.rs` keeps the bespoke `Number` enum (with its
// custom `serde(from/into = "JsonNumber")` + the 8 `From<uN/iN/fN>`
// impls); the rest of the module is constructors + tests. Re-export
Expand Down
34 changes: 1 addition & 33 deletions codama-nodes/src/value_nodes/value_node.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,6 @@
use crate::{
ArrayValueNode, BooleanValueNode, BytesValueNode, ConstantValueNode, EnumValueNode, HasKind,
MapEntryValueNode, MapValueNode, NoneValueNode, NumberValueNode, PublicKeyValueNode,
SetValueNode, SomeValueNode, StringValueNode, StructFieldValueNode, StructValueNode,
TupleValueNode,
};
use codama_nodes_derive::{node_union, RegisteredNodes};

#[derive(RegisteredNodes)]
#[node_union]
pub enum RegisteredValueNode {
Array(ArrayValueNode),
Boolean(BooleanValueNode),
Bytes(BytesValueNode),
Constant(ConstantValueNode),
Enum(EnumValueNode),
Map(MapValueNode),
None(NoneValueNode),
Number(NumberValueNode),
PublicKey(PublicKeyValueNode),
Set(SetValueNode),
Some(SomeValueNode),
String(StringValueNode),
Struct(StructValueNode),
Tuple(TupleValueNode),

#[registered]
StructField(StructFieldValueNode),
#[registered]
MapEntry(MapEntryValueNode),
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{HasKind, NoneValueNode, RegisteredValueNode, ValueNode};

#[test]
fn kind_from_standalone() {
Expand Down
17 changes: 0 additions & 17 deletions spec-generators/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,6 @@ export const CATEGORY_DIRECTORIES: ReadonlyMap<string, string> = new Map([
['value', 'value_nodes'],
]);

/**
* Spec union names the generator must NOT emit because their Rust
* counterpart is bespoke hand-written code that can't be reproduced
* mechanically.
*
* - `valueNode` is the category dispatch union for the `value`
* category, but in Rust it's the auto-derived standalone twin of
* `RegisteredValueNode` (`#[derive(RegisteredNodes)]`), which has
* the `registered`/`standalone` split with `#[registered]`
* variants. The generator doesn't model that yet; the
* hand-written `value_nodes/value_node.rs` stays canonical.
*
* Per-node structs in these categories are still generated normally;
* only the category union itself is skipped.
*/
export const HAND_WRITTEN_UNIONS: ReadonlySet<string> = new Set(['valueNode']);

/**
* Per-field Rust-type overrides for cases where the spec's TypeExpr
* maps to a bespoke Rust type that can't be expressed mechanically.
Expand Down
1 change: 1 addition & 0 deletions spec-generators/src/fragments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export * from './modPage';
export * from './nodePage';
export * from './nodeStructFragment';
export * from './page';
export * from './registeredUnionPage';
export * from './typeExpr';
export * from './unionPage';
38 changes: 25 additions & 13 deletions spec-generators/src/fragments/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { type Fragment, fragment, type ImportMap, importMapToString, mergeFragme
* Render a Rust source page from a body fragment: prepend a `use`
* block built from the fragment's import map, then the body content.
*
* `use crate::Foo;` lines are collapsed into a single
* `use crate::{Foo, Bar};` to match the hand-written convention;
* non-`crate::` paths stay on their own lines, sorted alphabetically.
* Imports sharing the same module prefix are collapsed into a single
* grouped `use <module>::{A, B};` line (e.g. `use crate::{Foo, Bar};`,
* `use codama_nodes_derive::{node_union, RegisteredNodes};`) to match
* the hand-written convention. Single-import modules stay on their
* own line (`use crate::Foo;`). Lines are sorted alphabetically by
* module path.
*
* This grouping has to happen here because stable `rustfmt` (the
* codama-rs toolchain) sorts `use` lines but won't merge them —
Expand All @@ -19,23 +22,32 @@ export function getPageFragment(body: Fragment): Fragment {
}

function formatImports(importMap: ImportMap): string {
// Each line is `use <path>;`. Split each path into `<module>::<name>`,
// grouping by module so multiple imports from the same module collapse.
const lines = importMapToString(importMap)
.split('\n')
.filter(line => line !== '');

const crateRefs: string[] = [];
const other: string[] = [];
const byModule = new Map<string, string[]>();
const ungroupable: string[] = [];
for (const line of lines) {
const match = /^use crate::([A-Za-z0-9_]+);$/.exec(line);
if (match) crateRefs.push(match[1]);
else other.push(line);
const match = /^use (.+)::([A-Za-z0-9_]+);$/.exec(line);
if (!match) {
ungroupable.push(line);
continue;
}
const [, mod, name] = match;
const names = byModule.get(mod) ?? [];
names.push(name);
byModule.set(mod, names);
}

const output: string[] = [];
if (crateRefs.length > 0) {
const sorted = [...crateRefs].toSorted((a, b) => a.localeCompare(b));
output.push(sorted.length === 1 ? `use crate::${sorted[0]};` : `use crate::{${sorted.join(', ')}};`);
const grouped: string[] = [];
for (const [mod, names] of byModule) {
const sorted = [...names].toSorted((a, b) => a.localeCompare(b));
grouped.push(sorted.length === 1 ? `use ${mod}::${sorted[0]};` : `use ${mod}::{${sorted.join(', ')}};`);
}
output.push(...other.toSorted((a, b) => a.localeCompare(b)));

const output = [...grouped, ...ungroupable].toSorted((a, b) => a.localeCompare(b));
return output.join('\n');
}
76 changes: 76 additions & 0 deletions spec-generators/src/fragments/registeredUnionPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { pascalCase } from '@codama/fragments';
import { addFragmentImports, type Fragment, fragment, mergeFragments } from '@codama/fragments/rust';
import type { Spec, UnionSpec } from '@codama/spec';

import { flattenNodeUnion, getRegisteredOnlyLeafKinds } from '../unions';
import { use } from './helpers';

/**
* The body for a category union that has `#[registered]`-only
* variants (e.g. `value`'s `RegisteredValueNode`). Emits:
*
* `#[derive(RegisteredNodes)] #[node_union] pub enum Registered<X> {
* <standalone variants, alphabetical>
*
* #[registered]
* <extra variant>
* …
* }`
*
* The `RegisteredNodes` derive macro produces the standalone twin
* (`X`) plus the `From`/`TryFrom` bridges between them, so the
* generated file replaces the entire hand-written `value_node.rs`
* pattern.
*
* Standalone variants are sorted alphabetically by their stripped
* variant name (consistent with `unionPage.ts`); `#[registered]`
* variants follow in spec declaration order (the `registered<X>`
* union's member order). The blank line between the two sections is
* preserved by `rustfmt`.
*/
export function getRegisteredUnionPageFragment(union: UnionSpec, spec: Spec): Fragment {
const enumName = `Registered${pascalCase(union.name)}`;
const suffix = pascalCase(union.name);

const registeredOnlyKinds = new Set(getRegisteredOnlyLeafKinds(union, spec));
const standaloneLeaves = [...flattenNodeUnion(union, spec)];
const standaloneVariants = standaloneLeaves
.map(node => ({ kind: node.kind, name: variantNameForKind(node.kind, suffix) }))
.toSorted((a, b) => a.name.localeCompare(b.name));
const registeredOnlyVariants = getRegisteredOnlyLeafKinds(union, spec).map(kind => ({
kind,
name: variantNameForKind(kind, suffix),
}));

// Sanity check: the registered-only kinds must not overlap with the standalone set.
for (const v of registeredOnlyVariants) {
if (!registeredOnlyKinds.has(v.kind)) {
throw new Error(`unexpected variant kind "${v.kind}" while building ${enumName}`);
}
}

const standaloneLines = mergeFragments(
standaloneVariants.map(v => fragment`${v.name}(${use(`crate::${pascalCase(v.kind)}`)}),`),
parts => parts.join('\n'),
);
const registeredLines = mergeFragments(
registeredOnlyVariants.map(v => fragment`#[registered]\n${v.name}(${use(`crate::${pascalCase(v.kind)}`)}),`),
parts => parts.join('\n'),
);

// Two sections separated by a blank line so rustfmt preserves the
// visual split between standalone and `#[registered]` variants.
const body = mergeFragments([standaloneLines, registeredLines], parts => parts.join('\n\n'));

// The `RegisteredNodes` derive macro expansion calls `.kind()` on
// each variant, which requires `HasKind` to be in scope.
return addFragmentImports(
fragment`#[derive(${use('codama_nodes_derive::RegisteredNodes')})]\n#[${use('codama_nodes_derive::node_union')}]\npub enum ${enumName} {\n${body}\n}`,
['crate::HasKind'],
);
}

function variantNameForKind(kind: string, suffix: string): string {
const pascal = pascalCase(kind);
return pascal.endsWith(suffix) ? pascal.slice(0, pascal.length - suffix.length) : pascal;
}
29 changes: 19 additions & 10 deletions spec-generators/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import { type Fragment } from '@codama/fragments/rust';
import { getSpec, type Spec } from '@codama/spec';

import { CATEGORY_ROUTING } from './defaults';
import { getModPagesRenderMap, getNodePageFragment, getPageFragment, getUnionPageFragment } from './fragments';
import {
getModPagesRenderMap,
getNodePageFragment,
getPageFragment,
getRegisteredUnionPageFragment,
getUnionPageFragment,
} from './fragments';
import {
buildRenderScope,
type GenerateOptions,
Expand All @@ -21,15 +27,9 @@ import {
validateRenderOptions,
} from './options';
import { getRepoDirectory } from './repoDirectory';
import { getEmittableUnions } from './unions';
import { getEmittableUnions, isRegisteredCategoryUnion } from './unions';

export {
CATEGORY_DIRECTORIES,
type CategoryRouting,
CATEGORY_ROUTING,
FIELD_TYPE_OVERRIDES,
HAND_WRITTEN_UNIONS,
} from './defaults';
export { CATEGORY_DIRECTORIES, type CategoryRouting, CATEGORY_ROUTING, FIELD_TYPE_OVERRIDES } from './defaults';
export {
buildRenderScope,
type GenerateOptions,
Expand Down Expand Up @@ -105,7 +105,16 @@ function getSpecPagesRenderMap(spec: Spec, scope: RenderScope): RenderMap<Fragme
// The on-disk file name follows the spec union name in
// snake_case (e.g. `linkNode` → `link_node.rs`).
const path = joinPath(folder, `${snakeCase(union.name)}.rs`);
entries[path] = getPageFragment(getUnionPageFragment(union, spec));
// Category unions whose `registered<X>` twin has extra
// `#[registered]`-only members (currently `value`, and in
// future `type` / `contextualValue`) are emitted via the
// `RegisteredNodes` derive; the standalone twin is then
// auto-derived. Other unions take the plain `#[node_union]`
// path.
const fragment = isRegisteredCategoryUnion(union, spec)
? getRegisteredUnionPageFragment(union, spec)
: getUnionPageFragment(union, spec);
entries[path] = getPageFragment(fragment);
}
}

Expand Down
42 changes: 35 additions & 7 deletions spec-generators/src/unions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { pascalCase } from '@codama/fragments';
import type { NodeSpec, Spec, UnionSpec } from '@codama/spec';

import { HAND_WRITTEN_UNIONS } from './defaults';

/**
* Spec unions starting with `registered` are category-registry unions
* (e.g. `registeredLinkNode`); the Rust crate exposes one flattened
Expand All @@ -22,22 +20,52 @@ const REGISTERED_UNION_PREFIX = 'registered';
* twin AND referenced by at least one node attribute somewhere
* in the spec. This rule is derived from the spec; no hand-list.
*
* Unions in {@link HAND_WRITTEN_UNIONS} are skipped — their Rust
* counterpart is bespoke (e.g. `valueNode` → `RegisteredValueNode`
* with `#[derive(RegisteredNodes)]`).
*
* Sorted alphabetically by name for stable output.
*/
export function getEmittableUnions(category: Spec['categories'][number], spec: Spec): readonly UnionSpec[] {
const referenced = getReferencedUnionNames(spec);
const allUnionNames = new Set(spec.categories.flatMap(c => c.unions).map(u => u.name));
return category.unions
.filter(u => !u.name.startsWith(REGISTERED_UNION_PREFIX))
.filter(u => !HAND_WRITTEN_UNIONS.has(u.name))
.filter(u => hasRegisteredTwin(u.name, allUnionNames) || isInlineUnion(u, allUnionNames, referenced))
.toSorted((a, b) => a.name.localeCompare(b.name));
}

/**
* `true` when the standalone `union` is the twin of a
* `registered<PascalCase>` that contains members the standalone
* doesn't (i.e. the `registered<X>` has at least one
* `#[registered]`-only variant). Such unions need the
* `#[derive(RegisteredNodes)]` emission mode — see
* `registeredUnionPage.ts`. Categories like `link`/`count` whose
* registered twin is identical to the standalone do NOT match.
*/
export function isRegisteredCategoryUnion(union: UnionSpec, spec: Spec): boolean {
const twin = spec.categories
.flatMap(c => c.unions)
.find(u => u.name === `${REGISTERED_UNION_PREFIX}${pascalCase(union.name)}`);
if (!twin) return false;
const standaloneKinds = new Set([...flattenNodeUnion(union, spec)].map(n => n.kind));
const twinKinds = [...flattenNodeUnion(twin, spec)].map(n => n.kind);
return twinKinds.some(k => !standaloneKinds.has(k));
}

/**
* The leaf node kinds that are present in the `registered<X>` twin
* but NOT in the standalone `<X>` — i.e. the variants that must be
* marked `#[registered]` in the emitted enum. Returned in spec
* declaration order (the `registered<X>` union's member order),
* since that order is deterministic and has no semantic effect.
*/
export function getRegisteredOnlyLeafKinds(union: UnionSpec, spec: Spec): readonly string[] {
const twin = spec.categories
.flatMap(c => c.unions)
.find(u => u.name === `${REGISTERED_UNION_PREFIX}${pascalCase(union.name)}`);
if (!twin) return [];
const standaloneKinds = new Set([...flattenNodeUnion(union, spec)].map(n => n.kind));
return [...flattenNodeUnion(twin, spec)].map(n => n.kind).filter(k => !standaloneKinds.has(k));
}

/**
* `true` when `union` is an inline / synthetic union: it has no
* `registered<PascalCase>` sibling anywhere in the spec AND it is
Expand Down
Loading
Loading