diff --git a/Cargo.lock b/Cargo.lock index 80d4762..3d4635d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2918,7 +2918,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbiont" -version = "0.3.0" +version = "0.4.0" dependencies = [ "criterion", "libloading", @@ -2937,8 +2937,9 @@ dependencies = [ [[package]] name = "symbiont-macros" -version = "0.3.0" +version = "0.4.0" dependencies = [ + "prettyplease", "proc-macro2", "quote", "syn 2.0.117", diff --git a/symbiont-macros/Cargo.toml b/symbiont-macros/Cargo.toml index 1aac48a..eee5534 100644 --- a/symbiont-macros/Cargo.toml +++ b/symbiont-macros/Cargo.toml @@ -7,7 +7,7 @@ keywords = ["agent", "dylib", "dynamic", "harness", "llm"] license = "MPL-2.0" name = "symbiont-macros" repository = "https://github.com/MathisWellmann/symbiont" -version = "0.3.0" +version = "0.4.0" [lints] workspace = true @@ -16,6 +16,7 @@ workspace = true proc-macro = true [dependencies] +prettyplease = "0.2" proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["extra-traits", "full"] } diff --git a/symbiont-macros/src/evolvable.rs b/symbiont-macros/src/evolvable.rs new file mode 100644 index 0000000..fffc6ae --- /dev/null +++ b/symbiont-macros/src/evolvable.rs @@ -0,0 +1,66 @@ +use syn::{ + ForeignItemFn, + ItemFn, + Signature, + Visibility, + parse::{ + Parse, + ParseStream, + }, +}; + +/// A single function declaration inside `evolvable! { ... }`. +/// +/// Supports two forms: +/// - With body: `fn step(counter: &mut usize) { *counter += 1; }` +/// - Without body: `fn step(counter: &mut usize);` +pub(crate) enum EvolvableFn { + WithBody(ItemFn), + WithoutBody(ForeignItemFn), +} + +impl EvolvableFn { + pub(crate) fn sig(&self) -> &Signature { + match self { + EvolvableFn::WithBody(f) => &f.sig, + EvolvableFn::WithoutBody(f) => &f.sig, + } + } + + pub(crate) fn vis(&self) -> &Visibility { + match self { + EvolvableFn::WithBody(f) => &f.vis, + EvolvableFn::WithoutBody(f) => &f.vis, + } + } + + pub(crate) fn attrs(&self) -> &[syn::Attribute] { + match self { + EvolvableFn::WithBody(f) => &f.attrs, + EvolvableFn::WithoutBody(f) => &f.attrs, + } + } +} + +/// The contents of an `evolvable! { ... }` block: zero or more function declarations. +#[expect(clippy::field_scoped_visibility_modifiers, reason = "Good here")] +pub(crate) struct EvolvableBlock { + pub(crate) functions: Vec, +} + +impl Parse for EvolvableBlock { + fn parse(input: ParseStream) -> syn::Result { + let mut functions = Vec::new(); + while !input.is_empty() { + // Try parsing as a full function (with body) first + let fork = input.fork(); + if fork.parse::().is_ok() { + functions.push(EvolvableFn::WithBody(input.parse::()?)); + } else { + // Fall back to bodyless (foreign-style) declaration + functions.push(EvolvableFn::WithoutBody(input.parse::()?)); + } + } + Ok(EvolvableBlock { functions }) + } +} diff --git a/symbiont-macros/src/full_source.rs b/symbiont-macros/src/full_source.rs new file mode 100644 index 0000000..d97e711 --- /dev/null +++ b/symbiont-macros/src/full_source.rs @@ -0,0 +1,90 @@ +use quote::quote; + +use crate::evolvable::EvolvableFn; + +/// Build the `full_source` string for the dylib from a function declaration. +/// +/// Forces `pub` visibility and prepends `#[unsafe(no_mangle)]`. +pub(crate) fn build_full_source(func: &EvolvableFn) -> String { + let sig = func.sig(); + + // Keep the body as a TokenStream so `quote!` splices it as code, not as a string literal. + let body_tokens: proc_macro2::TokenStream = match func { + EvolvableFn::WithBody(f) => { + let block = &f.block; + quote!(#block) + } + EvolvableFn::WithoutBody(_) => quote!({ todo!() }), + }; + + let inputs = &sig.inputs; + let output = &sig.output; + let ident = &sig.ident; + + // Preserve doc comments on the generated function so they are available in the + // dylib's source for tooling and documentation purposes. Render them as `///` + // line comments rather than `#[doc = "..."]` attributes for readability. + use std::fmt::Write as _; + let doc_lines: String = func + .attrs() + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .filter_map(extract_doc_string) + .fold(String::new(), |mut acc, line| { + let _ = writeln!(acc, "///{line}"); + acc + }); + + let fn_tokens = quote! { + #[unsafe(no_mangle)] + pub fn #ident(#inputs) #output #body_tokens + }; + + // `quote!` above always produces a valid `fn` item, so parsing as a + // `syn::File` is infallible. Run it through `prettyplease` so the emitted + // source reads like hand-written Rust rather than a single token-spaced line. + let file = syn::parse2::(fn_tokens).expect("generated fn is valid Rust"); + let formatted = prettyplease::unparse(&file); + + format!("{doc_lines}{formatted}") +} + +/// Extract the string value from a `#[doc = "..."]` attribute. +fn extract_doc_string(attr: &syn::Attribute) -> Option { + if let syn::Meta::NameValue(nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = &nv.value + { + return Some(s.value()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::EvolvableBlock; + + #[test] + fn test_build_full_source() { + let func = quote!( + /// Should increment the counter by a value in the range 5..20 + fn step(counter: &mut usize) { + *counter += 1; + println!("doing stuff in iteration {}", counter); + } + ); + let block: EvolvableBlock = syn::parse2(func).expect("parse evolvable block"); + assert_eq!( + build_full_source(&block.functions[0]), + "/// Should increment the counter by a value in the range 5..20\n\ + #[unsafe(no_mangle)]\n\ + pub fn step(counter: &mut usize) {\n \ + *counter += 1;\n \ + println!(\"doing stuff in iteration {}\", counter);\n\ + }\n", + ); + } +} diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index 0e9dc92..aff3601 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -4,6 +4,9 @@ //! Provides the [`evolvable!`] function-like macro that declares //! hot-reloadable functions and generates dispatch wrappers. +mod evolvable; +mod full_source; + use proc_macro::TokenStream; use proc_macro2::Span; use quote::{ @@ -12,71 +15,14 @@ use quote::{ }; use syn::{ FnArg, - ForeignItemFn, - ItemFn, ReturnType, Signature, - Visibility, - parse::{ - Parse, - ParseStream, - }, }; -/// A single function declaration inside `evolvable! { ... }`. -/// -/// Supports two forms: -/// - With body: `fn step(counter: &mut usize) { *counter += 1; }` -/// - Without body: `fn step(counter: &mut usize);` -enum EvolvableFn { - WithBody(ItemFn), - WithoutBody(ForeignItemFn), -} - -impl EvolvableFn { - fn sig(&self) -> &Signature { - match self { - EvolvableFn::WithBody(f) => &f.sig, - EvolvableFn::WithoutBody(f) => &f.sig, - } - } - - fn vis(&self) -> &Visibility { - match self { - EvolvableFn::WithBody(f) => &f.vis, - EvolvableFn::WithoutBody(f) => &f.vis, - } - } - - fn attrs(&self) -> &[syn::Attribute] { - match self { - EvolvableFn::WithBody(f) => &f.attrs, - EvolvableFn::WithoutBody(f) => &f.attrs, - } - } -} - -/// The contents of an `evolvable! { ... }` block: zero or more function declarations. -struct EvolvableBlock { - functions: Vec, -} - -impl Parse for EvolvableBlock { - fn parse(input: ParseStream) -> syn::Result { - let mut functions = Vec::new(); - while !input.is_empty() { - // Try parsing as a full function (with body) first - let fork = input.fork(); - if fork.parse::().is_ok() { - functions.push(EvolvableFn::WithBody(input.parse::()?)); - } else { - // Fall back to bodyless (foreign-style) declaration - functions.push(EvolvableFn::WithoutBody(input.parse::()?)); - } - } - Ok(EvolvableBlock { functions }) - } -} +use crate::{ + evolvable::EvolvableBlock, + full_source::build_full_source, +}; /// Format a `syn::Signature` into a human-readable string like `"fn step(counter: &mut usize)"`. /// @@ -124,60 +70,6 @@ fn normalize_tokens(mut s: String) -> String { s } -/// Extract the string value from a `#[doc = "..."]` attribute. -fn extract_doc_string(attr: &syn::Attribute) -> Option { - if let syn::Meta::NameValue(nv) = &attr.meta - && let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(s), - .. - }) = &nv.value - { - return Some(s.value()); - } - None -} - -/// Build the `full_source` string for the dylib from a function declaration. -/// -/// Forces `pub` visibility and prepends `#[unsafe(no_mangle)]`. -fn build_full_source(func: &EvolvableFn) -> String { - let sig = func.sig(); - - // Keep the body as a TokenStream so `quote!` splices it as code, not as a string literal. - let body_tokens: proc_macro2::TokenStream = match func { - EvolvableFn::WithBody(f) => { - let block = &f.block; - quote!(#block) - } - EvolvableFn::WithoutBody(_) => quote!({ todo!() }), - }; - - let inputs = &sig.inputs; - let output = &sig.output; - let ident = &sig.ident; - - // Preserve doc comments on the generated function so they are available in the - // dylib's source for tooling and documentation purposes. Render them as `///` - // line comments rather than `#[doc = "..."]` attributes for readability. - use std::fmt::Write as _; - let doc_lines: String = func - .attrs() - .iter() - .filter(|attr| attr.path().is_ident("doc")) - .filter_map(extract_doc_string) - .fold(String::new(), |mut acc, line| { - let _ = writeln!(acc, "///{line}"); - acc - }); - - let fn_body = quote! { - #[unsafe(no_mangle)] - pub fn #ident(#inputs) #output #body_tokens - }; - - format!("{doc_lines}{fn_body}\n").trim_start().to_string() -} - /// Declare hot-reloadable functions that are compiled into a temporary dylib and loaded at runtime. /// /// # Examples diff --git a/symbiont/Cargo.toml b/symbiont/Cargo.toml index 1af7f1d..9b2b553 100644 --- a/symbiont/Cargo.toml +++ b/symbiont/Cargo.toml @@ -9,13 +9,13 @@ license = "MPL-2.0" name = "symbiont" readme = "README.md" repository = "https://github.com/MathisWellmann/symbiont" -version = "0.3.0" +version = "0.4.0" [lints] workspace = true [dependencies] -symbiont-macros = { version = "0.3.0", path = "../symbiont-macros" } +symbiont-macros = { version = "0.4.0", path = "../symbiont-macros" } rig-core.workspace = true tokio.workspace = true diff --git a/symbiont/src/decl.rs b/symbiont/src/decl.rs index 4e71933..5e13016 100644 --- a/symbiont/src/decl.rs +++ b/symbiont/src/decl.rs @@ -1,11 +1,14 @@ // SPDX-License-Identifier: MPL-2.0 -use std::sync::atomic::AtomicPtr; +use std::{ + fmt, + ops::Deref, + sync::atomic::AtomicPtr, +}; /// A declaration of an evolvable function, generated by the `evolvable!` macro. /// /// Contains the metadata needed to create the temporary dylib crate and validate /// LLM-generated code against the expected function signatures. -#[derive(Debug)] pub struct EvolvableDecl { /// Function name (e.g., `"step"`). pub name: &'static str, @@ -18,6 +21,126 @@ pub struct EvolvableDecl { pub fn_ptr: &'static AtomicPtr<()>, } +impl fmt::Debug for EvolvableDecl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EvolvableDecl") + .field("name", &self.name) + .field("signature", &self.signature) + .field("full_source", &FullSource(self.full_source)) + .field("fn_ptr", &self.fn_ptr) + .finish() + } +} + +/// Wrapper around a multi-line Rust source string that renders with real line +/// breaks under pretty-print (`{:#?}`), instead of the default escaped +/// single-line form. +/// +/// Behaves like a `&str` for all practical purposes: dereferences to `str`, +/// implements `AsRef` and `Display` (which writes the source verbatim). +#[derive(Clone, Copy)] +pub struct FullSource<'a>(pub &'a str); + +impl FullSource<'_> { + /// Borrow the underlying source string. + #[inline] + pub fn as_str(&self) -> &str { + self.0 + } +} + +impl fmt::Debug for FullSource<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if f.alternate() { + // Render each source line indented one level deeper than the + // surrounding context. Avoid emitting a trailing newline so any + // following punctuation (e.g. the `,` from `debug_struct` / + // `debug_list`) sits directly after the last source line. + let trimmed = self.0.trim_end_matches('\n'); + for line in trimmed.lines() { + f.write_str("\n ")?; + f.write_str(line)?; + } + Ok(()) + } else { + fmt::Debug::fmt(self.0, f) + } + } +} + +impl fmt::Display for FullSource<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0) + } +} + +impl Deref for FullSource<'_> { + type Target = str; + + #[inline] + fn deref(&self) -> &str { + self.0 + } +} + +impl AsRef for FullSource<'_> { + #[inline] + fn as_ref(&self) -> &str { + self.0 + } +} + +impl<'a> From<&'a str> for FullSource<'a> { + #[inline] + fn from(s: &'a str) -> Self { + Self(s) + } +} + // SAFETY: AtomicPtr is Send+Sync, and the other fields are &'static str. unsafe impl Send for EvolvableDecl {} unsafe impl Sync for EvolvableDecl {} + +#[cfg(test)] +mod tests { + use std::ptr; + + use super::*; + + #[test] + fn debug_renders_full_source_with_real_line_breaks() { + static PTR: AtomicPtr<()> = AtomicPtr::new(ptr::null_mut()); + let decl = EvolvableDecl { + name: "step", + signature: "fn step(counter: &mut usize)", + full_source: "/// Should increment the counter by a value in the range 5..20\n\ + #[unsafe(no_mangle)]\n\ + pub fn step(counter: &mut usize) {\n \ + *counter += 1;\n\ + }\n", + fn_ptr: &PTR, + }; + + let pretty = format!("{decl:#?}"); + // Source lines should be present verbatim (no `\n` escapes). + assert!(pretty.contains("\n #[unsafe(no_mangle)]\n")); + assert!(pretty.contains("\n pub fn step(counter: &mut usize) {\n")); + assert!(pretty.contains("\n *counter += 1;\n")); + assert!(!pretty.contains(r"\n")); + } + + #[test] + fn full_source_vec_pretty_prints_each_entry() { + let sources = vec![ + FullSource("#[unsafe(no_mangle)]\npub fn a() {}\n"), + FullSource("#[unsafe(no_mangle)]\npub fn b() {\n todo!()\n}\n"), + ]; + + let pretty = format!("{sources:#?}"); + assert!(pretty.contains("\n #[unsafe(no_mangle)]\n")); + assert!(pretty.contains("\n pub fn a() {}")); + assert!(pretty.contains("\n pub fn b() {\n")); + assert!(pretty.contains("\n todo!()\n")); + assert!(!pretty.contains(r"\n")); + } +} diff --git a/symbiont/src/lib.rs b/symbiont/src/lib.rs index afabb15..c06ddfe 100644 --- a/symbiont/src/lib.rs +++ b/symbiont/src/lib.rs @@ -19,7 +19,10 @@ mod validation; // Re-export the proc macro. // Re-export key types. pub use compiler::Profile; -pub use decl::EvolvableDecl; +pub use decl::{ + EvolvableDecl, + FullSource, +}; pub use error::{ Error, Result, diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index 10e25dd..99d25b5 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -46,6 +46,7 @@ use tracing::{ use crate::{ EvolvableDecl, + FullSource, compiler::{ Profile, compile_dylib, @@ -55,6 +56,12 @@ use crate::{ Result, }, parser::parse_rust_code, + utils::{ + dylib_extension, + find_so, + generate_cargo_toml, + generate_lib_rs, + }, validation::validate_generated_ast, }; @@ -233,8 +240,11 @@ impl Runtime { } /// Get the full function signatures, including doc comments and default function body. - pub fn fn_full_sources(&self) -> Vec<&'static str> { - Vec::from_iter(self.decls.iter().map(|d| d.full_source)) + /// + /// Returns each source wrapped in [`FullSource`], which preserves real line + /// breaks when pretty-printed (`{:#?}`) so logs stay readable. + pub fn fn_full_sources(&self) -> Vec> { + Vec::from_iter(self.decls.iter().map(|d| FullSource(d.full_source))) } /// Generate LLM response, then parse, validate, compile, and hot-swap. @@ -398,70 +408,3 @@ impl Runtime { std::fs::read_to_string(self.crate_dir.join("src").join("clean.rs")) } } - -fn generate_cargo_toml() -> String { - r#"[package] -name = "symbiont-evolvable" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["dylib"] - -# Ensure panics unwind rather than abort so that `symbiont::catch_panic` -# can intercept them across the dylib boundary. -[profile.dev] -panic = "unwind" - -[profile.release] -panic = "unwind" - -[dependencies] -"# - .to_string() -} - -fn generate_lib_rs(decls: &[EvolvableDecl]) -> String { - let mut src = String::with_capacity(1_000); - for d in decls { - src.push_str(d.full_source); - src.push_str("\n\n"); - } - src -} - -fn dylib_extension() -> &'static str { - if cfg!(target_os = "macos") { - ".dylib" - } else if cfg!(target_os = "windows") { - ".dll" - } else { - ".so" - } -} - -/// Find the compiled shared library in the temp crate's target directory. -fn find_so(crate_dir: &Path, profile: Profile) -> Result { - let subdir = match profile { - Profile::Debug => "debug", - Profile::Release => "release", - }; - let target_dir = crate_dir.join("target").join(subdir); - - let prefix = if cfg!(target_os = "windows") { - "" - } else { - "lib" - }; - let name = format!("{prefix}symbiont_evolvable{ext}", ext = dylib_extension()); - let so_path = target_dir.join(&name); - - if so_path.exists() { - Ok(so_path) - } else { - Err(Error::DylibLoad(format!( - "Compiled dylib not found at {}", - so_path.display() - ))) - } -} diff --git a/symbiont/src/utils.rs b/symbiont/src/utils.rs index a6130a3..d86c42c 100644 --- a/symbiont/src/utils.rs +++ b/symbiont/src/utils.rs @@ -1,6 +1,18 @@ +use std::path::{ + Path, + PathBuf, +}; + // SPDX-License-Identifier: MPL-2.0 use syn::ItemFn; +use crate::{ + Error, + EvolvableDecl, + Profile, + Result, +}; + /// If `true`, the function has visibiliity `pub` #[inline(always)] pub(crate) fn is_pub(item_fn: &ItemFn) -> bool { @@ -17,6 +29,72 @@ pub(crate) fn is_no_mangle(item_fn: &ItemFn) -> bool { }) } +pub(crate) fn generate_cargo_toml() -> String { + r#"[package] +name = "symbiont-evolvable" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["dylib"] + +# Ensure panics unwind rather than abort so that `symbiont::catch_panic` +# can intercept them across the dylib boundary. +[profile.dev] +panic = "unwind" + +[profile.release] +panic = "unwind" + +[dependencies] +"# + .to_string() +} + +pub(crate) fn generate_lib_rs(decls: &[EvolvableDecl]) -> String { + let mut src = String::with_capacity(1_000); + for d in decls { + src.push_str(d.full_source); + src.push_str("\n\n"); + } + src +} + +pub(crate) fn dylib_extension() -> &'static str { + if cfg!(target_os = "macos") { + ".dylib" + } else if cfg!(target_os = "windows") { + ".dll" + } else { + ".so" + } +} + +/// Find the compiled shared library in the temp crate's target directory. +pub(crate) fn find_so(crate_dir: &Path, profile: Profile) -> Result { + let subdir = match profile { + Profile::Debug => "debug", + Profile::Release => "release", + }; + let target_dir = crate_dir.join("target").join(subdir); + + let prefix = if cfg!(target_os = "windows") { + "" + } else { + "lib" + }; + let name = format!("{prefix}symbiont_evolvable{ext}", ext = dylib_extension()); + let so_path = target_dir.join(&name); + + if so_path.exists() { + Ok(so_path) + } else { + Err(Error::DylibLoad(format!( + "Compiled dylib not found at {}", + so_path.display() + ))) + } +} #[cfg(test)] mod tests { use super::*;