Skip to content
Open
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
53 changes: 35 additions & 18 deletions rust/crates/truapi-codegen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::str::FromStr;

mod platform;
mod platform_callbacks;
mod rust;
mod rustdoc;
mod ts;
Expand Down Expand Up @@ -72,6 +73,11 @@ struct Cli {
#[arg(long)]
platform_wasm_adapter_output: Option<String>,

/// Output directory for the generated Rust WASM platform bridge.
/// Only honored when `--platform-input` is also set.
#[arg(long)]
platform_rust_output: Option<PathBuf>,

/// Output directory for generated explorer metadata (optional). When set,
/// writes `codegen/types.ts` with the DataType list consumed by the
/// explorer site.
Expand Down Expand Up @@ -145,33 +151,44 @@ fn main() -> Result<()> {
.with_context(|| format!("writing Rust dispatcher to {}", path.display()))?;
println!("Wrote Rust dispatcher to {}", path.display());
}
if let (Some(input), Some(output)) = (&cli.platform_input, &cli.platform_ts_output) {
if let Some(input) = &cli.platform_input {
if cli.platform_wasm_adapter_output.is_some() && cli.platform_ts_output.is_none() {
anyhow::bail!("--platform-wasm-adapter-output requires --platform-ts-output");
}
let json = std::fs::read_to_string(input)
.with_context(|| format!("reading platform rustdoc JSON from {input}"))?;
let krate =
rustdoc::parse(&json).with_context(|| format!("parsing platform rustdoc {input}"))?;
let definition = platform::extract(&krate)
.with_context(|| format!("extracting platform definition from {input}"))?;
let codec_types = api
.types
.iter()
.filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_)))
.map(|t| t.name.clone())
.collect();
let adapter_output = cli
.platform_wasm_adapter_output
.as_deref()
.unwrap_or(output.as_str());
ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output)
.with_context(|| format!("writing host callbacks TS to {output}"))?;
println!("Generated typed HostCallbacks TS surface in {output}");
println!("Generated WASM HostCallbacks adapter in {adapter_output}");
} else if cli.platform_input.is_some() != cli.platform_ts_output.is_some()
if let Some(output) = &cli.platform_ts_output {
let codec_types = api
.types
.iter()
.filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_)))
.map(|t| t.name.clone())
.collect();
let adapter_output = cli
.platform_wasm_adapter_output
.as_deref()
.unwrap_or(output.as_str());
ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output)
.with_context(|| format!("writing host callbacks TS to {output}"))?;
println!("Generated typed HostCallbacks TS surface in {output}");
println!("Generated WASM HostCallbacks adapter in {adapter_output}");
}
if let Some(output) = &cli.platform_rust_output {
rust::generate_wasm_bridge_file(&definition, &api, output)
.with_context(|| format!("writing Rust WASM bridge to {}", output.display()))?;
println!("Generated Rust WASM bridge in {}", output.display());
}
} else if cli.platform_ts_output.is_some()
|| cli.platform_wasm_adapter_output.is_some()
|| cli.platform_rust_output.is_some()
{
anyhow::bail!(
"--platform-input and --platform-ts-output must be provided together; \
--platform-wasm-adapter-output additionally requires both"
"--platform-input is required for platform output; \
--platform-wasm-adapter-output additionally requires --platform-ts-output"
);
}
if let Some(path) = &cli.explorer_output {
Expand Down
181 changes: 181 additions & 0 deletions rust/crates/truapi-codegen/src/platform_callbacks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Shared callback naming and selection rules for generated platform bridges.

use std::collections::BTreeSet;

use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait};
use crate::rustdoc::TypeRef;

pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> {
let composed: BTreeSet<String> = match &definition.super_trait {
Some(s) => s.composes.iter().cloned().collect(),
None => definition.traits.iter().map(|t| t.name.clone()).collect(),
};
definition
.traits
.iter()
.filter(|t| composed.contains(&t.name))
.collect()
}

pub(crate) fn raw_callback_name(method: &PlatformMethod) -> String {
to_camel_case(&method.name)
}

pub(crate) fn platform_trait_names(definition: &PlatformDefinition) -> BTreeSet<String> {
definition.traits.iter().map(|t| t.name.clone()).collect()
}

pub(crate) fn trait_object_return_name<'a>(
method: &'a PlatformMethod,
platform_trait_names: &BTreeSet<String>,
) -> Option<&'a str> {
match &method.return_shape.inner {
PlatformInner::TraitObject(name) => Some(name.as_str()),
PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => {
named_platform_trait(ok, platform_trait_names)
}
PlatformInner::Unit | PlatformInner::Stream(_) => None,
}
}

pub(crate) fn raw_callback_wire_name(
trait_def: &PlatformTrait,
method: &PlatformMethod,
platform_trait_names: &BTreeSet<String>,
) -> String {
let raw = raw_callback_name(method);
if trait_object_return_name(method, platform_trait_names).is_some() {
return format!(
"{}{}",
callback_namespace(&trait_def.name),
upper_first(&raw)
);
}
raw
}

pub(crate) fn raw_callback_field_name(
trait_def: &PlatformTrait,
method: &PlatformMethod,
platform_trait_names: &BTreeSet<String>,
) -> String {
snake_case(&raw_callback_wire_name(
trait_def,
method,
platform_trait_names,
))
}

pub(crate) fn raw_callback_type_name(
trait_def: &PlatformTrait,
method: &PlatformMethod,
platform_trait_names: &BTreeSet<String>,
) -> String {
upper_first(&raw_callback_wire_name(
trait_def,
method,
platform_trait_names,
))
}

pub(crate) fn raw_callback_adapter_name(
trait_def: &PlatformTrait,
method: &PlatformMethod,
platform_trait_names: &BTreeSet<String>,
) -> String {
format!(
"{}Adapter",
raw_callback_wire_name(trait_def, method, platform_trait_names)
)
}

pub(crate) fn callback_namespace(trait_name: &str) -> String {
let stem = ["Provider", "Presenter", "Host"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No Duplicate protection, meaning that a FooHost and FooProvider would silently emit duplicate keys.

Also some tests on this file would help catch edge cases like this one

.into_iter()
.find_map(|suffix| trait_name.strip_suffix(suffix))
.unwrap_or(trait_name);
lower_pascal_case(stem)
}

fn named_platform_trait<'a>(
ty: &'a TypeRef,
platform_trait_names: &BTreeSet<String>,
) -> Option<&'a str> {
let TypeRef::Named { name, args } = ty else {
return None;
};
if args.is_empty() && platform_trait_names.contains(name) {
return Some(name.as_str());
}
None
}

/// Unwrap a `Result<T, E>` stream item to its `T`; other item types pass
/// through. Streams carry `Result`s on the Rust side but the JS raw bridge
/// already unwraps them before handing each item to the WASM callback sink.
pub(crate) fn stream_item(item: &TypeRef) -> &TypeRef {
if let TypeRef::Named { name, args } = item
&& name == "Result"
&& let Some(ok) = args.first()
{
return ok;
}
item
}

pub(crate) fn to_camel_case(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut upper_next = false;
for (idx, ch) in name.chars().enumerate() {
if ch == '_' {
upper_next = idx != 0;
continue;
}
if upper_next {
out.extend(ch.to_uppercase());
upper_next = false;
} else {
out.push(ch);
}
}
out
}

fn lower_pascal_case(name: &str) -> String {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!(
"{}{}",
first.to_ascii_lowercase(),
chars.collect::<String>()
)
}

fn upper_first(name: &str) -> String {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!(
"{}{}",
first.to_ascii_uppercase(),
chars.collect::<String>()
)
}

pub(crate) fn snake_case(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 4);
for (idx, ch) in name.chars().enumerate() {
if ch.is_ascii_uppercase() {
if idx != 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
17 changes: 17 additions & 0 deletions rust/crates/truapi-codegen/src/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ use anyhow::Result;

use convert_case::{Case, Casing};

use crate::platform::PlatformDefinition;
use crate::rustdoc::*;

mod dispatcher;
mod wasm_bridge;
mod wire_table;

pub use dispatcher::generate_dispatcher;
pub use wasm_bridge::generate_wasm_bridge;
pub use wire_table::generate_wire_table;

/// Generates the Rust wire dispatcher and wire-table sources into `output_dir`.
Expand All @@ -29,6 +32,20 @@ pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> {
Ok(())
}

/// Generates the Rust wasm-bindgen platform bridge source into `output_dir`.
pub fn generate_wasm_bridge_file(
definition: &PlatformDefinition,
api: &ApiDefinition,
output_dir: &Path,
) -> Result<()> {
fs::create_dir_all(output_dir)?;
fs::write(
output_dir.join("generated_bridge.rs"),
generate_wasm_bridge(definition, api)?,
)?;
Ok(())
}

/// Trait -> versioned-module mapping. Trait names are PascalCase
/// (`JsonRpc`, `LocalStorage`); module names are snake_case
/// (`jsonrpc`, `local_storage`). The mapping is irregular enough
Expand Down
Loading
Loading