#![forbid(unsafe_code)]
#![warn(missing_docs, missing_debug_implementations)]
use std::{
collections::{btree_map::Entry, BTreeMap},
path::{Path, PathBuf},
};
use proc_macro2::{TokenStream, TokenTree};
use quote::{format_ident, quote};
fn validate_mod_name(name: &str, full: &str) {
if name == "gen" {
panic!("module name {name:?} (in {full:?}) is a Rust keyword");
}
if !name.starts_with("r#") && syn::parse_str::<syn::Ident>(name).is_ok() {
return;
}
let is_keyword = !name.starts_with("r#")
&& name.parse::<TokenStream>().is_ok_and(|ts| {
let mut trees = ts.into_iter();
matches!(
(trees.next(), trees.next()),
(Some(TokenTree::Ident(ident)), None) if ident == name
)
});
if is_keyword {
panic!("module name {name:?} (in {full:?}) is a Rust keyword");
}
panic!(
"module name {name:?} (in {full:?}) is not a valid Rust identifier \
(raw identifiers are not supported)",
);
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Visibility {
#[default]
Pub,
Crate,
Private,
}
impl Visibility {
fn to_tokens(self) -> TokenStream {
match self {
Visibility::Pub => quote! { pub },
Visibility::Crate => quote! { pub(crate) },
Visibility::Private => TokenStream::new(),
}
}
}
#[derive(Debug, Default)]
pub struct Codespace {
root: Mod,
}
impl Codespace {
pub fn add_item(&mut self, path: impl Into<String>, tokens: TokenStream) {
let path = path.into();
let mut segs = path.split("::").peekable();
let mut m = &mut self.root;
loop {
let seg = segs.next().expect("path must not be empty");
if segs.peek().is_none() {
m.add_item(seg, tokens);
return;
}
validate_mod_name(seg, &path);
m = m.mods.entry(seg.to_string()).or_default();
}
}
pub fn add_mod(&mut self, name: impl Into<String>, m: Mod) {
self.root.add_mod(name, m);
}
pub fn get_root_mod(&mut self) -> &mut Mod {
&mut self.root
}
pub fn into_root_mod(self) -> Mod {
self.root
}
pub fn into_stream(self) -> TokenStream {
let mut out = self.root.inner_meta();
out.extend(self.root.into_stream());
out
}
pub fn into_files(self) -> BTreeMap<PathBuf, TokenStream> {
let mut files = BTreeMap::new();
let mut contents = self.root.inner_meta();
contents.extend(self.root.into_file_contents(Path::new(""), &mut files));
files.insert(PathBuf::from("lib.rs"), contents);
files
}
}
#[derive(Debug, Default)]
pub struct Mod {
items: BTreeMap<String, TokenStream>,
mods: BTreeMap<String, Mod>,
vis: Visibility,
docs: Vec<String>,
attrs: Vec<TokenStream>,
}
impl Mod {
pub fn add_item(&mut self, key: impl Into<String>, tokens: TokenStream) {
self.items
.entry(key.into())
.and_modify(|existing| existing.extend(tokens.clone()))
.or_insert(tokens);
}
pub fn get_mod(&mut self, name: impl Into<String>) -> &mut Mod {
let name = name.into();
validate_mod_name(&name, &name);
self.mods.entry(name).or_default()
}
pub fn has_mod(&self, name: &str) -> bool {
self.mods.contains_key(name)
}
pub fn replace_mod(&mut self, name: impl Into<String>, m: Mod) -> Option<Mod> {
let name = name.into();
validate_mod_name(&name, &name);
self.mods.insert(name, m)
}
pub fn merge(&mut self, other: Mod) {
for (key, tokens) in other.items {
self.add_item(key, tokens);
}
for (name, m) in other.mods {
match self.mods.entry(name) {
Entry::Occupied(e) => e.into_mut().merge(m),
Entry::Vacant(e) => {
e.insert(m);
}
}
}
self.docs.extend(other.docs);
self.attrs.extend(other.attrs);
}
pub fn add_mod(&mut self, name: impl Into<String>, m: Mod) {
let name = name.into();
validate_mod_name(&name, &name);
match self.mods.entry(name) {
Entry::Occupied(e) => e.into_mut().merge(m),
Entry::Vacant(e) => {
e.insert(m);
}
}
}
pub fn set_visibility(&mut self, vis: Visibility) {
self.vis = vis;
}
pub fn add_docs(&mut self, docs: impl Into<String>) {
self.docs.push(docs.into());
}
pub fn add_attr(&mut self, attr: TokenStream) {
self.attrs.push(attr);
}
fn outer_meta(&self) -> TokenStream {
let mut out = TokenStream::new();
for (ii, block) in self.docs.iter().enumerate() {
if ii > 0 {
out.extend(quote! { #[doc = ""] });
}
for line in block.lines() {
out.extend(quote! { #[doc = #line] });
}
}
for attr in &self.attrs {
out.extend(quote! { #[#attr] });
}
out
}
fn inner_meta(&self) -> TokenStream {
let mut out = TokenStream::new();
for (ii, block) in self.docs.iter().enumerate() {
if ii > 0 {
out.extend(quote! { #![doc = ""] });
}
for line in block.lines() {
out.extend(quote! { #![doc = #line] });
}
}
for attr in &self.attrs {
out.extend(quote! { #![#attr] });
}
out
}
fn into_stream(self) -> TokenStream {
let mut out = TokenStream::new();
for (_, tokens) in self.items {
out.extend(tokens);
}
for (name, m) in self.mods {
let ident = format_ident!("{}", name);
let meta = m.outer_meta();
let vis = m.vis.to_tokens();
let contents = m.into_stream();
out.extend(quote! {
#meta
#vis mod #ident {
#contents
}
});
}
out
}
fn into_file_contents(
self,
dir: &Path,
files: &mut BTreeMap<PathBuf, TokenStream>,
) -> TokenStream {
let mut out = TokenStream::new();
for (name, m) in self.mods {
let ident = format_ident!("{}", name);
let meta = m.outer_meta();
let vis = m.vis.to_tokens();
out.extend(quote! {
#meta
#vis mod #ident;
});
let (file, child_dir) = if m.mods.is_empty() {
(dir.join(format!("{name}.rs")), dir.to_path_buf())
} else {
let child_dir = dir.join(&name);
(child_dir.join("mod.rs"), child_dir)
};
let contents = m.into_file_contents(&child_dir, files);
files.insert(file, contents);
}
for (_, tokens) in self.items {
out.extend(tokens);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn empty_codespace() {
let cs = Codespace::default();
assert!(cs.into_stream().is_empty());
}
#[test]
fn root_items_are_flat() {
let mut cs = Codespace::default();
cs.add_item("Foo", quote! { pub struct Foo; });
cs.add_item("Bar", quote! { pub struct Bar; });
let out = cs.into_stream().to_string();
assert!(out.contains("struct Bar"));
assert!(out.contains("struct Foo"));
let bar_pos = out.find("struct Bar").unwrap();
let foo_pos = out.find("struct Foo").unwrap();
assert!(bar_pos < foo_pos, "expected alphabetical order");
}
#[test]
fn extend_same_key() {
let mut cs = Codespace::default();
cs.add_item("Foo", quote! { pub struct Foo; });
cs.add_item("Foo", quote! { impl Foo {} });
let out = cs.into_stream().to_string();
assert!(out.contains("struct Foo"));
assert!(out.contains("impl Foo"));
}
#[test]
fn path_creates_submod() {
let mut cs = Codespace::default();
cs.add_item("Foo", quote! { pub struct Foo; });
cs.add_item("defaults::foo_value", quote! { pub fn foo_value() {} });
let out = cs.into_stream().to_string();
assert!(out.contains("pub mod defaults"));
assert!(out.contains("fn foo_value"));
let item_pos = out.find("struct Foo").unwrap();
let mod_pos = out.find("pub mod defaults").unwrap();
assert!(item_pos < mod_pos, "items precede submodules");
}
#[test]
fn path_nested() {
let mut cs = Codespace::default();
cs.add_item("outer::inner::deep", quote! { pub fn deep() {} });
let out = cs.into_stream().to_string();
assert!(out.contains("pub mod outer"));
assert!(out.contains("pub mod inner"));
assert!(out.contains("fn deep"));
}
#[test]
fn items_before_mods() {
let mut cs = Codespace::default();
cs.add_item("zzz", quote! { pub struct Zzz; });
cs.add_item("aaa::f", quote! { pub fn f() {} });
let out = cs.into_stream().to_string();
let item_pos = out.find("struct Zzz").unwrap();
let mod_pos = out.find("pub mod aaa").unwrap();
assert!(item_pos < mod_pos, "items always precede submodules");
}
#[test]
fn get_root_mod_direct_access() {
let mut cs = Codespace::default();
cs.get_root_mod()
.get_mod("defaults")
.add_item("f", quote! { pub fn f() {} });
let out = cs.into_stream().to_string();
assert!(out.contains("pub mod defaults"));
assert!(out.contains("fn f"));
}
#[test]
fn same_path_twice_extends() {
let mut cs = Codespace::default();
cs.add_item("defaults::a", quote! { pub fn a() {} });
cs.add_item("defaults::b", quote! { pub fn b() {} });
let out = cs.into_stream().to_string();
let mod_start = out.find("pub mod defaults").unwrap();
assert!(out[mod_start..].contains("fn a"));
assert!(out[mod_start..].contains("fn b"));
}
#[test]
fn item_and_mod_may_share_name() {
let mut cs = Codespace::default();
cs.add_item("foo", quote! { pub struct Foo; });
cs.add_item("foo::bar", quote! { pub fn bar() {} });
let out = cs.into_stream().to_string();
assert!(out.contains("struct Foo"));
assert!(out.contains("pub mod foo"));
assert!(out.contains("fn bar"));
}
#[test]
#[should_panic(expected = "is not a valid Rust identifier")]
fn invalid_mod_name_panics() {
let mut cs = Codespace::default();
cs.add_item("not-valid-ident::key", quote! {});
}
#[test]
fn mod_add_item_colons_are_literal_sort_key() {
let mut cs = Codespace::default();
cs.get_root_mod().add_item("a::b", quote! { pub struct X; });
let out = cs.into_stream().to_string();
assert!(!out.contains("pub mod"), "no submodule expected");
assert!(out.contains("struct X"));
}
#[test]
fn special_sort_key_orders_before_alpha() {
let mut cs = Codespace::default();
cs.get_root_mod()
.add_item("Foo", quote! { pub struct Foo; });
cs.get_root_mod()
.add_item("#preamble", quote! { use std::collections::BTreeMap; });
let out = cs.into_stream().to_string();
let use_pos = out.find("BTreeMap").unwrap();
let foo_pos = out.find("struct Foo").unwrap();
assert!(use_pos < foo_pos, "#preamble should sort before Foo");
}
#[test]
fn multiple_submods_alphabetical() {
let mut cs = Codespace::default();
cs.add_item("zzz::a", quote! { pub fn a() {} });
cs.add_item("aaa::b", quote! { pub fn b() {} });
cs.add_item("mmm::c", quote! { pub fn c() {} });
let out = cs.into_stream().to_string();
let aaa = out.find("mod aaa").unwrap();
let mmm = out.find("mod mmm").unwrap();
let zzz = out.find("mod zzz").unwrap();
assert!(
aaa < mmm && mmm < zzz,
"submods should appear alphabetically"
);
}
#[test]
#[should_panic(expected = "is not a valid Rust identifier")]
fn get_mod_invalid_ident_panics() {
let mut cs = Codespace::default();
cs.get_root_mod().get_mod("not-valid");
}
#[test]
#[should_panic(expected = "is a Rust keyword")]
fn keyword_mod_name_panics() {
let mut cs = Codespace::default();
cs.add_item("type::key", quote! {});
}
#[test]
#[should_panic(expected = "is a Rust keyword")]
fn get_mod_keyword_panics() {
let mut cs = Codespace::default();
cs.get_root_mod().get_mod("type");
}
#[test]
#[should_panic(expected = "is a Rust keyword")]
fn gen_mod_name_panics() {
let mut cs = Codespace::default();
cs.get_root_mod().get_mod("gen");
}
#[test]
#[should_panic(expected = "raw identifiers are not supported")]
fn raw_ident_mod_name_panics() {
let mut cs = Codespace::default();
cs.get_root_mod().get_mod("r#type");
}
#[test]
fn has_mod_true_for_existing_false_for_absent() {
let mut cs = Codespace::default();
cs.get_root_mod().get_mod("foo");
assert!(cs.get_root_mod().has_mod("foo"));
assert!(!cs.get_root_mod().has_mod("bar"));
}
#[test]
fn has_mod_does_not_create() {
let mut cs = Codespace::default();
assert!(!cs.get_root_mod().has_mod("foo"));
let out = cs.into_stream().to_string();
assert!(!out.contains("mod foo"));
}
fn no_ws(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
#[test]
fn visibility_pub_renders() {
let mut cs = Codespace::default();
cs.get_root_mod()
.get_mod("foo")
.add_item("f", quote! { pub fn f() {} });
let out = no_ws(&cs.into_stream().to_string());
assert!(out.contains("pubmodfoo{"));
}
#[test]
fn visibility_crate_renders() {
let mut cs = Codespace::default();
let m = cs.get_root_mod().get_mod("foo");
m.set_visibility(Visibility::Crate);
m.add_item("f", quote! { pub fn f() {} });
let out = no_ws(&cs.into_stream().to_string());
assert!(out.contains("pub(crate)modfoo{"));
}
#[test]
fn visibility_private_renders() {
let mut cs = Codespace::default();
let m = cs.get_root_mod().get_mod("foo");
m.set_visibility(Visibility::Private);
m.add_item("f", quote! { pub fn f() {} });
let out = no_ws(&cs.into_stream().to_string());
assert!(out.contains("modfoo{"));
assert!(!out.contains("pubmodfoo"));
assert!(!out.contains("pub(crate)"));
}
#[test]
fn submodule_docs_and_attrs_are_outer() {
let mut cs = Codespace::default();
let m = cs.get_root_mod().get_mod("helpers");
m.add_docs("Helper functions.");
m.add_attr(quote! { allow(dead_code) });
m.add_item("f", quote! { pub fn f() {} });
let out = no_ws(&cs.into_stream().to_string());
assert!(out.contains(r#"#[doc="Helperfunctions."]"#));
assert!(out.contains(r#"#[allow(dead_code)]pubmodhelpers{"#));
assert!(!out.contains("#!"));
}
#[test]
fn merge_appends_items_on_key_collision() {
let mut a = Mod::default();
a.add_item("Foo", quote! { pub struct Foo; });
let mut b = Mod::default();
b.add_item("Foo", quote! { impl Foo {} });
b.add_item("Bar", quote! { pub struct Bar; });
a.merge(b);
let mut cs = Codespace::default();
*cs.get_root_mod() = a;
let out = cs.into_stream().to_string();
assert!(out.contains("struct Foo"));
assert!(out.contains("impl Foo"));
assert!(out.contains("struct Bar"));
let bar_pos = out.find("struct Bar").unwrap();
let foo_pos = out.find("struct Foo").unwrap();
assert!(bar_pos < foo_pos);
}
#[test]
fn merge_recursive_submodules() {
let mut a = Mod::default();
a.get_mod("shared").add_item("a", quote! { pub fn a() {} });
let mut b = Mod::default();
b.get_mod("shared").add_item("b", quote! { pub fn b() {} });
b.get_mod("only_b").add_item("c", quote! { pub fn c() {} });
a.merge(b);
let mut cs = Codespace::default();
*cs.get_root_mod() = a;
let out = cs.into_stream().to_string();
assert_eq!(out.matches("mod shared").count(), 1);
let only_b = out.find("mod only_b").unwrap();
let shared = out.find("mod shared").unwrap();
assert!(only_b < shared);
assert!(out[only_b..shared].contains("fn c"));
assert!(out[shared..].contains("fn a"));
assert!(out[shared..].contains("fn b"));
}
#[test]
fn merge_docs_attrs_and_visibility() {
let mut a = Mod::default();
a.add_docs("First.");
a.add_attr(quote! { allow(dead_code) });
a.set_visibility(Visibility::Crate);
let mut b = Mod::default();
b.add_docs("Second.");
b.add_attr(quote! { allow(unused) });
b.set_visibility(Visibility::Private);
a.merge(b);
let mut cs = Codespace::default();
cs.get_root_mod().add_mod("m", a);
let out = no_ws(&cs.into_stream().to_string());
assert!(out.contains(r#"#[doc="First."]#[doc=""]#[doc="Second."]"#));
assert!(out.contains("#[allow(dead_code)]#[allow(unused)]"));
assert!(out.contains("pub(crate)modm"));
}
#[test]
fn replace_mod_clobbers_existing() {
let mut root = Mod::default();
let mut m1 = Mod::default();
m1.add_item("f", quote! { pub fn f() {} });
assert!(root.replace_mod("client", m1).is_none());
let mut m2 = Mod::default();
m2.add_item("g", quote! { pub fn g() {} });
let prev = root.replace_mod("client", m2).unwrap();
assert!(prev.items.contains_key("f"));
let mut cs = Codespace::default();
*cs.get_root_mod() = root;
let out = cs.into_stream().to_string();
assert_eq!(out.matches("mod client").count(), 1);
assert!(out.contains("fn g"));
assert!(!out.contains("fn f"));
}
#[test]
#[should_panic(expected = "is a Rust keyword")]
fn replace_mod_keyword_panics() {
let mut root = Mod::default();
root.replace_mod("type", Mod::default());
}
#[test]
fn into_root_mod_mounts_in_another_codespace() {
let mut inner = Codespace::default();
inner.add_item("Foo", quote! { pub struct Foo; });
inner.add_item("defaults::f", quote! { pub fn f() {} });
let mut outer = Codespace::default();
outer.add_item("Bar", quote! { pub struct Bar; });
outer.add_mod("subsystem", inner.into_root_mod());
let out = no_ws(&outer.into_stream().to_string());
assert!(out.contains("pubstructBar"));
assert!(out.contains("pubmodsubsystem{"));
assert!(out.contains("pubstructFoo"));
assert!(out.contains("pubmoddefaults{"));
}
#[test]
fn add_mod_inserts_new() {
let mut cs = Codespace::default();
let mut m = Mod::default();
m.add_item("f", quote! { pub fn f() {} });
cs.add_mod("client", m);
let out = cs.into_stream().to_string();
assert!(out.contains("pub mod client"));
assert!(out.contains("fn f"));
}
#[test]
fn add_mod_merges_on_collision() {
let mut cs = Codespace::default();
cs.add_item("client::a", quote! { pub fn a() {} });
let mut m = Mod::default();
m.add_item("b", quote! { pub fn b() {} });
cs.add_mod("client", m);
let out = cs.into_stream().to_string();
assert_eq!(out.matches("mod client").count(), 1);
assert!(out.contains("fn a"));
assert!(out.contains("fn b"));
}
#[test]
#[should_panic(expected = "is a Rust keyword")]
fn add_mod_keyword_panics() {
let mut cs = Codespace::default();
cs.add_mod("type", Mod::default());
}
#[test]
fn into_files_adaptive_layout() {
let mut cs = Codespace::default();
cs.add_item("Root", quote! { pub struct Root; });
cs.add_item("a::f", quote! { pub fn f() {} });
cs.add_item("b::g", quote! { pub fn g() {} });
cs.add_item("b::c::h", quote! { pub fn h() {} });
cs.add_item("b::c::d::i", quote! { pub fn i() {} });
let files = cs.into_files();
let paths = files.keys().map(PathBuf::as_path).collect::<Vec<_>>();
assert_eq!(
paths,
["a.rs", "b/c/d.rs", "b/c/mod.rs", "b/mod.rs", "lib.rs"].map(Path::new)
);
let lib = no_ws(&files[Path::new("lib.rs")].to_string());
assert!(lib.contains("pubstructRoot"));
assert!(lib.contains("pubmoda;"));
assert!(lib.contains("pubmodb;"));
let root_pos = lib.find("pubstructRoot").unwrap();
let a_pos = lib.find("pubmoda;").unwrap();
let b_pos = lib.find("pubmodb;").unwrap();
assert!(a_pos < b_pos && b_pos < root_pos);
let a = no_ws(&files[Path::new("a.rs")].to_string());
assert!(a.contains("pubfnf()"));
assert!(!a.contains("mod"));
let b = no_ws(&files[Path::new("b/mod.rs")].to_string());
assert!(b.contains("pubfng()"));
assert!(b.contains("pubmodc;"));
assert!(b.find("pubmodc;").unwrap() < b.find("pubfng()").unwrap());
let c = no_ws(&files[Path::new("b/c/mod.rs")].to_string());
assert!(c.contains("pubfnh()"));
assert!(c.contains("pubmodd;"));
let d = no_ws(&files[Path::new("b/c/d.rs")].to_string());
assert!(d.contains("pubfni()"));
}
#[test]
fn into_files_decl_carries_metadata() {
let mut cs = Codespace::default();
let m = cs.get_root_mod().get_mod("helpers");
m.set_visibility(Visibility::Crate);
m.add_docs("Helper functions.");
m.add_attr(quote! { allow(dead_code) });
m.add_item("f", quote! { pub fn f() {} });
let files = cs.into_files();
let lib = no_ws(&files[Path::new("lib.rs")].to_string());
assert!(
lib.contains(r#"#[doc="Helperfunctions."]#[allow(dead_code)]pub(crate)modhelpers;"#)
);
let helpers = no_ws(&files[Path::new("helpers.rs")].to_string());
assert!(!helpers.contains("doc"));
assert!(!helpers.contains("allow"));
}
#[test]
fn into_files_non_leaf_decl_carries_metadata() {
let mut cs = Codespace::default();
let m = cs.get_root_mod().get_mod("outer");
m.set_visibility(Visibility::Private);
m.add_docs("Outer module.");
m.add_attr(quote! { allow(dead_code) });
m.get_mod("inner").add_item("f", quote! { pub fn f() {} });
let files = cs.into_files();
let lib = no_ws(&files[Path::new("lib.rs")].to_string());
assert!(lib.contains(r#"#[doc="Outermodule."]#[allow(dead_code)]modouter;"#));
assert!(!lib.contains("pubmodouter"));
let outer = no_ws(&files[Path::new("outer/mod.rs")].to_string());
assert_eq!(outer, "pubmodinner;");
}
#[test]
fn into_files_root_meta_is_inner() {
let mut cs = Codespace::default();
cs.get_root_mod().add_docs("Generated code.");
cs.get_root_mod().add_attr(quote! { allow(clippy::all) });
cs.add_item("Foo", quote! { pub struct Foo; });
let files = cs.into_files();
let lib = no_ws(&files[Path::new("lib.rs")].to_string());
assert!(lib.starts_with(r#"#![doc="Generatedcode."]#![allow(clippy::all)]"#));
}
#[test]
fn into_files_itemless_parent_mod() {
let mut cs = Codespace::default();
cs.add_item("outer::inner::f", quote! { pub fn f() {} });
let files = cs.into_files();
let paths = files.keys().map(PathBuf::as_path).collect::<Vec<_>>();
assert_eq!(
paths,
["lib.rs", "outer/inner.rs", "outer/mod.rs"].map(Path::new)
);
let outer = no_ws(&files[Path::new("outer/mod.rs")].to_string());
assert_eq!(outer, "pubmodinner;");
let inner = no_ws(&files[Path::new("outer/inner.rs")].to_string());
assert!(inner.contains("pubfnf()"));
}
#[test]
fn into_files_empty_codespace() {
let files = Codespace::default().into_files();
assert_eq!(files.len(), 1);
assert!(files[Path::new("lib.rs")].is_empty());
}
#[test]
fn add_docs_appends_as_paragraphs() {
let mut cs = Codespace::default();
let m = cs.get_root_mod().get_mod("helpers");
m.add_docs("First paragraph.\nStill the first.");
m.add_docs("Second paragraph.");
m.add_item("f", quote! { pub fn f() {} });
let out = no_ws(&cs.into_stream().to_string());
assert!(out.contains(
r#"#[doc="Firstparagraph."]#[doc="Stillthefirst."]#[doc=""]#[doc="Secondparagraph."]"#
));
}
#[test]
fn root_docs_and_attrs_are_inner() {
let mut cs = Codespace::default();
cs.get_root_mod().add_docs("Generated code.\nDo not edit.");
cs.get_root_mod().add_attr(quote! { allow(clippy::all) });
cs.add_item("Foo", quote! { pub struct Foo; });
let out = no_ws(&cs.into_stream().to_string());
assert!(out
.starts_with(r#"#![doc="Generatedcode."]#![doc="Donotedit."]#![allow(clippy::all)]"#));
assert!(out.contains("pubstructFoo"));
}
}