diff --git a/README.md b/README.md index 99fa0c7..8591a2f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,40 @@ The core of why VBARE was designed this way is: - Stop making big breaking v1 to v2 changes — instead, **make much smaller schema changes** with more flexibility - Schema evolution frequently requires more than just renaming properties (like Protobuf, Flatbuffers, Cap'n'proto) — **more complicated reshaping & fetching data** from remote sources is commonly needed +## Imports (Rust compiler) + +Share types between schema files with an import before any type declarations: + +```bare +#import "shared/example.types.v1.bare" + +type Message struct { + key: SharedKey + previous: optional +} +``` + +Paths are relative to the schema root passed to `process_schemas_with_config`, +including for importers in subdirectories. The compiler recursively discovers +`.bare` files. Each import names an exact file, so importing `v1` continues to use +that version even when `v2` exists. A shared file's version does not change the +importer's version. + +Each file is generated once. Strip `.bare` and replace dots and directory +separators with underscores to get its Rust module name: for example, +`shared/example.types.v1.bare` becomes `shared_example_types_v1`. +`combined_imports.rs` includes every module. Importers use the shared modules' +types directly, without generating copies. The low-level `SchemaLoader` API also +accepts an explicit schema root; `bare_schema` uses the schema's parent directory. + +Imports are transitive. Cycles, missing files, conflicting type definitions, local +types shadowing imported names, and module-name collisions are errors. +Reaching the same defining file through multiple imports is allowed. Import +paths must stay within the schema root and cannot contain `..`. + +The TypeScript compiler delegates parsing to `@bare-ts/tools`; this Rust import +extension is not implemented by that external parser. + ## Implementations - [TypeScript](./typescript/) diff --git a/fixtures/tests/imports/conflict/a.v1.bare b/fixtures/tests/imports/conflict/a.v1.bare new file mode 100644 index 0000000..2ff7d02 --- /dev/null +++ b/fixtures/tests/imports/conflict/a.v1.bare @@ -0,0 +1,3 @@ +#import "b.v1.bare" +#import "c.v1.bare" +type A struct { value: Shared } diff --git a/fixtures/tests/imports/conflict/b.v1.bare b/fixtures/tests/imports/conflict/b.v1.bare new file mode 100644 index 0000000..4e8fff8 --- /dev/null +++ b/fixtures/tests/imports/conflict/b.v1.bare @@ -0,0 +1 @@ +type Shared u64 diff --git a/fixtures/tests/imports/conflict/c.v1.bare b/fixtures/tests/imports/conflict/c.v1.bare new file mode 100644 index 0000000..9604628 --- /dev/null +++ b/fixtures/tests/imports/conflict/c.v1.bare @@ -0,0 +1 @@ +type Shared str diff --git a/fixtures/tests/imports/cycle/a.v1.bare b/fixtures/tests/imports/cycle/a.v1.bare new file mode 100644 index 0000000..79cd4be --- /dev/null +++ b/fixtures/tests/imports/cycle/a.v1.bare @@ -0,0 +1,2 @@ +#import "b.v1.bare" +type A u64 diff --git a/fixtures/tests/imports/cycle/b.v1.bare b/fixtures/tests/imports/cycle/b.v1.bare new file mode 100644 index 0000000..ab1e7bc --- /dev/null +++ b/fixtures/tests/imports/cycle/b.v1.bare @@ -0,0 +1,2 @@ +#import "a.v1.bare" +type B u64 diff --git a/fixtures/tests/imports/escape/a.v1.bare b/fixtures/tests/imports/escape/a.v1.bare new file mode 100644 index 0000000..7ee18ec --- /dev/null +++ b/fixtures/tests/imports/escape/a.v1.bare @@ -0,0 +1,2 @@ +#import "../valid/simple.v1.bare" +type A u64 diff --git a/fixtures/tests/imports/late/a.v1.bare b/fixtures/tests/imports/late/a.v1.bare new file mode 100644 index 0000000..9fc6d2a --- /dev/null +++ b/fixtures/tests/imports/late/a.v1.bare @@ -0,0 +1,2 @@ +type A u64 +#import "b.v1.bare" diff --git a/fixtures/tests/imports/late/b.v1.bare b/fixtures/tests/imports/late/b.v1.bare new file mode 100644 index 0000000..5bf3483 --- /dev/null +++ b/fixtures/tests/imports/late/b.v1.bare @@ -0,0 +1 @@ +type B u64 diff --git a/fixtures/tests/imports/malformed/a.v1.bare b/fixtures/tests/imports/malformed/a.v1.bare new file mode 100644 index 0000000..6fc4753 --- /dev/null +++ b/fixtures/tests/imports/malformed/a.v1.bare @@ -0,0 +1,2 @@ +#import b.v1.bare +type A u64 diff --git a/fixtures/tests/imports/missing/a.v1.bare b/fixtures/tests/imports/missing/a.v1.bare new file mode 100644 index 0000000..2732c3d --- /dev/null +++ b/fixtures/tests/imports/missing/a.v1.bare @@ -0,0 +1,2 @@ +#import "shared/missing.v1.bare" +type A u64 diff --git a/fixtures/tests/imports/module-conflict/shared/a.v1.bare b/fixtures/tests/imports/module-conflict/shared/a.v1.bare new file mode 100644 index 0000000..9de8b53 --- /dev/null +++ b/fixtures/tests/imports/module-conflict/shared/a.v1.bare @@ -0,0 +1 @@ +type A u64 diff --git a/fixtures/tests/imports/module-conflict/shared_a.v1.bare b/fixtures/tests/imports/module-conflict/shared_a.v1.bare new file mode 100644 index 0000000..5bf3483 --- /dev/null +++ b/fixtures/tests/imports/module-conflict/shared_a.v1.bare @@ -0,0 +1 @@ +type B u64 diff --git a/fixtures/tests/imports/shadow/a.v1.bare b/fixtures/tests/imports/shadow/a.v1.bare new file mode 100644 index 0000000..124cdc7 --- /dev/null +++ b/fixtures/tests/imports/shadow/a.v1.bare @@ -0,0 +1,2 @@ +#import "b.v1.bare" +type Shared str diff --git a/fixtures/tests/imports/shadow/b.v1.bare b/fixtures/tests/imports/shadow/b.v1.bare new file mode 100644 index 0000000..4e8fff8 --- /dev/null +++ b/fixtures/tests/imports/shadow/b.v1.bare @@ -0,0 +1 @@ +type Shared u64 diff --git a/fixtures/tests/imports/valid/diamond.v1.bare b/fixtures/tests/imports/valid/diamond.v1.bare new file mode 100644 index 0000000..98ae019 --- /dev/null +++ b/fixtures/tests/imports/valid/diamond.v1.bare @@ -0,0 +1,3 @@ +#import "shared/middle.v2.bare" +#import "shared/common.v1.bare" +type Diamond struct { key: SharedKey } diff --git a/fixtures/tests/imports/valid/nested/transitive.v3.bare b/fixtures/tests/imports/valid/nested/transitive.v3.bare new file mode 100644 index 0000000..02a44ed --- /dev/null +++ b/fixtures/tests/imports/valid/nested/transitive.v3.bare @@ -0,0 +1,10 @@ +#import "shared/middle.v2.bare" +type Choice union { SharedKey | Fraction | Empty | Payload } +type Message struct { + middle: Middle + key: optional + keys: list + fraction: Fraction + payload: Payload + choice: Choice +} diff --git a/fixtures/tests/imports/valid/pinned.v1.bare b/fixtures/tests/imports/valid/pinned.v1.bare new file mode 100644 index 0000000..1a6096b --- /dev/null +++ b/fixtures/tests/imports/valid/pinned.v1.bare @@ -0,0 +1,2 @@ +#import "shared/common.v7.bare" +type Pinned struct { key: SharedKey } diff --git a/fixtures/tests/imports/valid/shared/common.v1.bare b/fixtures/tests/imports/valid/shared/common.v1.bare new file mode 100644 index 0000000..bb24909 --- /dev/null +++ b/fixtures/tests/imports/valid/shared/common.v1.bare @@ -0,0 +1,4 @@ +type SharedKey struct { value: u64 } +type Fraction struct { value: f64 } +type Payload data +type Empty void diff --git a/fixtures/tests/imports/valid/shared/common.v7.bare b/fixtures/tests/imports/valid/shared/common.v7.bare new file mode 100644 index 0000000..530242c --- /dev/null +++ b/fixtures/tests/imports/valid/shared/common.v7.bare @@ -0,0 +1 @@ +type SharedKey struct { value: str } diff --git a/fixtures/tests/imports/valid/shared/middle.v2.bare b/fixtures/tests/imports/valid/shared/middle.v2.bare new file mode 100644 index 0000000..e12d92c --- /dev/null +++ b/fixtures/tests/imports/valid/shared/middle.v2.bare @@ -0,0 +1,2 @@ +#import "shared/common.v1.bare" +type Middle struct { key: SharedKey } diff --git a/fixtures/tests/imports/valid/simple.v1.bare b/fixtures/tests/imports/valid/simple.v1.bare new file mode 100644 index 0000000..9acff3e --- /dev/null +++ b/fixtures/tests/imports/valid/simple.v1.bare @@ -0,0 +1,2 @@ +#import "shared/common.v1.bare" +type Simple struct { key: SharedKey } diff --git a/rust/examples/basic/Cargo.toml b/rust/examples/basic/Cargo.toml index 294a8d2..dff8290 100644 --- a/rust/examples/basic/Cargo.toml +++ b/rust/examples/basic/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" [dependencies] anyhow = { workspace = true } serde = { workspace = true } +serde_bytes = "0.11" serde_bare = { workspace = true } vbare = { path = "../../vbare" } diff --git a/rust/examples/basic/build.rs b/rust/examples/basic/build.rs index 560cf5c..9c484f9 100644 --- a/rust/examples/basic/build.rs +++ b/rust/examples/basic/build.rs @@ -1,6 +1,14 @@ use std::path::Path; fn main() -> Result<(), Box> { + let imports = Path::new("../../../fixtures/tests/imports/valid"); + vbare_compiler::process_schemas(imports)?; + let out = std::path::PathBuf::from(std::env::var("OUT_DIR")?); + std::fs::rename( + out.join("combined_imports.rs"), + out.join("import_fixtures.rs"), + )?; + let schemas = Path::new("schemas"); vbare_compiler::process_schemas(schemas)?; Ok(()) diff --git a/rust/examples/basic/tests/imports.rs b/rust/examples/basic/tests/imports.rs new file mode 100644 index 0000000..f9e16e8 --- /dev/null +++ b/rust/examples/basic/tests/imports.rs @@ -0,0 +1,38 @@ +mod schemas { + include!(concat!(env!("OUT_DIR"), "/import_fixtures.rs")); +} + +#[test] +fn shared_type_identity_and_round_trip_survive_transitive_imports() { + use schemas::{ + nested_transitive_v3::{Choice, Message}, + shared_common_v1::{Fraction, SharedKey}, + shared_middle_v2::Middle, + }; + let _: schemas::shared_common_v1::Empty = (); + let key = SharedKey { value: 42 }; + let simple = schemas::simple_v1::Simple { key: key.clone() }; + let diamond = schemas::diamond_v1::Diamond { key: simple.key }; + let message = Message { + middle: Middle { key: diamond.key }, + key: Some(key.clone()), + keys: vec![key], + fraction: Fraction { value: 0.5 }, + payload: vec![1, 2, 3], + choice: Choice::Empty, + }; + let bytes = serde_bare::to_vec(&message).unwrap(); + let decoded: Message = serde_bare::from_slice(&bytes).unwrap(); + assert_eq!(decoded, message); + let with_shared_variant = Choice::SharedKey(message.middle.key); + let bytes = serde_bare::to_vec(&with_shared_variant).unwrap(); + assert_eq!( + serde_bare::from_slice::(&bytes).unwrap(), + with_shared_variant + ); + // The imported v7 type does not change the importer's v1 module or the v1 shared type. + let pinned = schemas::pinned_v1::Pinned { + key: schemas::shared_common_v7::SharedKey { value: "v7".into() }, + }; + assert_eq!(pinned.key.value, "v7"); +} diff --git a/rust/vbare-compiler/src/lib.rs b/rust/vbare-compiler/src/lib.rs index bb81452..78a182c 100644 --- a/rust/vbare-compiler/src/lib.rs +++ b/rust/vbare-compiler/src/lib.rs @@ -1,5 +1,9 @@ use indoc::formatdoc; -use std::{fs, path::Path}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; /// Configuration for the vbare-compiler. /// @@ -29,54 +33,80 @@ pub fn process_schemas_with_config( config: &Config, ) -> Result<(), Box> { let out_dir = std::env::var("OUT_DIR")?; - let out_path = Path::new(&out_dir); + process_schemas_to_dir(schema_dir, Path::new(&out_dir), config) +} +/// Generate all `.bare` files recursively, using `schema_dir` as the import root. +/// Directory names prefix module names; dots and separators become underscores. +pub fn process_schemas_to_dir( + schema_dir: &Path, + out_path: &Path, + config: &Config, +) -> Result<(), Box> { println!("cargo:rerun-if-changed={}", schema_dir.display()); - - let mut all_names = Vec::new(); - - for entry in fs::read_dir(schema_dir)?.flatten() { - let path = entry.path(); - - if path.is_dir() { - continue; + let mut paths = Vec::new(); + collect_schemas(schema_dir, Path::new(""), &mut paths)?; + paths.sort(); + let mut names = BTreeMap::new(); + for path in &paths { + let name = vbare_gen::module_name(path)?; + if let Some(previous) = names.insert(name.clone(), path.clone()) { + return Err(format!( + "schemas {} and {} map to the same module {name}", + previous.display(), + path.display() + ) + .into()); } - - let bare_name = path - .file_name() - .ok_or("No file name")? - .to_str() - .ok_or("Invalid UTF-8 in file name")? - .rsplit_once('.') - .ok_or("No file extension")? - .0; - - let tokens = vbare_gen::bare_schema(&path, config.vbare); + } + let mut loader = vbare_gen::SchemaLoader::new(schema_dir); + let mut mod_content = String::from("// Auto-generated module file for schemas\n\n"); + for path in paths { + let tokens = loader.generate(&path, config.vbare)?; let ast = syn::parse2(tokens)?; let content = prettyplease::unparse(&ast); - - fs::write(out_path.join(format!("{bare_name}_generated.rs")), content)?; - - all_names.push(bare_name.to_string()); - } - - let mut mod_content = String::new(); - mod_content.push_str("// Auto-generated module file for schemas\n\n"); - - for name in all_names { - let module_name = name.replace('.', "_"); + let module_name = vbare_gen::module_name(&path)?; + // Preserve existing top-level output filenames for build script consumers. + let output_name = path + .with_extension("") + .components() + .map(|part| { + part.as_os_str() + .to_str() + .ok_or("Invalid UTF-8 in file name") + }) + .collect::, _>>()? + .join("_"); + fs::write( + out_path.join(format!("{output_name}_generated.rs")), + content, + )?; mod_content.push_str(&formatdoc!( r#" pub mod {module_name} {{ - include!(concat!(env!("OUT_DIR"), "/{name}_generated.rs")); + include!(concat!(env!("OUT_DIR"), "/{output_name}_generated.rs")); }} "#, - name = name, - module_name = module_name, )); } - fs::write(out_path.join("combined_imports.rs"), mod_content)?; + Ok(()) +} +fn collect_schemas(root: &Path, relative: &Path, paths: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(root.join(relative))? { + let entry = entry?; + let path = relative.join(entry.file_name()); + let kind = entry.file_type()?; + if kind.is_dir() { + collect_schemas(root, &path, paths)?; + } else if kind.is_file() + && path + .extension() + .is_some_and(|extension| extension == "bare") + { + paths.push(path); + } + } Ok(()) } diff --git a/rust/vbare-compiler/tests/imports.rs b/rust/vbare-compiler/tests/imports.rs new file mode 100644 index 0000000..61b86ca --- /dev/null +++ b/rust/vbare-compiler/tests/imports.rs @@ -0,0 +1,66 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn fixtures(case: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/tests/imports") + .join(case) +} + +#[test] +fn generates_shared_types_once_and_imports_transitive_modules() { + let output = tempfile::tempdir().unwrap(); + vbare_compiler::process_schemas_to_dir(&fixtures("valid"), output.path(), &Default::default()) + .unwrap(); + let modules = fs::read_to_string(output.path().join("combined_imports.rs")).unwrap(); + assert_eq!(modules.matches("pub mod shared_common_v1").count(), 1); + assert!(modules.contains("pub mod shared_common_v7")); + assert!(modules.contains("pub mod nested_transitive_v3")); + let code = fs::read_to_string(output.path().join("nested_transitive.v3_generated.rs")).unwrap(); + assert!(code.contains("use super::shared_common_v1::*;")); + assert!(code.contains("use super::shared_middle_v2::*;")); + assert!(!code.contains("pub struct SharedKey")); + assert!(code.contains("pub key: Option")); + assert!(code.contains("pub keys: Vec")); + assert!( + !code.contains("Eq, Hash"), + "floating imported types must prevent Eq/Hash derives" + ); + let pinned = fs::read_to_string(output.path().join("pinned.v1_generated.rs")).unwrap(); + assert!(pinned.contains("use super::shared_common_v7::*;")); + assert!(!pinned.contains("shared_common_v1")); +} + +#[test] +fn reports_invalid_imports_with_file_context() { + for (case, expected) in [ + ("cycle", vec!["cycle", "a.v1.bare", "b.v1.bare"]), + ("missing", vec!["a.v1.bare", "shared/missing.v1.bare"]), + ("conflict", vec!["Shared", "b.v1.bare", "c.v1.bare"]), + ("shadow", vec!["Shared", "a.v1.bare", "b.v1.bare"]), + ("late", vec!["a.v1.bare"]), + ("malformed", vec!["a.v1.bare"]), + ("escape", vec!["a.v1.bare", "root-relative"]), + ( + "module-conflict", + vec!["shared/a.v1.bare", "shared_a.v1.bare", "same module"], + ), + ] { + let output = tempfile::tempdir().unwrap(); + let error = vbare_compiler::process_schemas_to_dir( + &fixtures(case), + output.path(), + &Default::default(), + ) + .expect_err(case) + .to_string(); + for expected in expected { + assert!( + error.contains(expected), + "{case}: expected {expected:?} in {error:?}" + ); + } + } +} diff --git a/rust/vbare-gen/src/grammar.pest b/rust/vbare-gen/src/grammar.pest index fd52a99..167a2e0 100644 --- a/rust/vbare-gen/src/grammar.pest +++ b/rust/vbare-gen/src/grammar.pest @@ -1,5 +1,6 @@ -//schema = { SOI ~ user_type+ ~ EOI } -schema = { SOI ~ user_type+ ~ EOI } +schema = { SOI ~ import* ~ user_type+ ~ EOI } +import = ${ "#import" ~ (" " | "\t")+ ~ "\"" ~ import_path ~ "\"" ~ (" " | "\t")* ~ (NEWLINE | EOI) } +import_path = @{ (! ("\"" | NEWLINE) ~ ANY)+ } type_l = _{ "type" } user_type = { "type" ~ user_type_name ~ any_type } user_type_name = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHANUMERIC | "_" | "-")* } @@ -43,4 +44,4 @@ any_type = _{ | primative_type } WHITESPACE = _{ " " | "\n" | "\t" | NEWLINE } -COMMENT = _{ "#" ~ (!NEWLINE ~ ANY)* ~ NEWLINE } +COMMENT = _{ !("#import" ~ (" " | "\t" | "\"" | NEWLINE | EOI)) ~ "#" ~ (!NEWLINE ~ ANY)* ~ (NEWLINE | EOI) } diff --git a/rust/vbare-gen/src/imports.rs b/rust/vbare-gen/src/imports.rs new file mode 100644 index 0000000..2b127ac --- /dev/null +++ b/rust/vbare-gen/src/imports.rs @@ -0,0 +1,173 @@ +//! Resolve root-relative schema imports once, preserving each type's defining module. + +use crate::{ + parser::{self, AnyType}, + Config, SchemaGenerator, +}; +use proc_macro2::TokenStream; +use quote::quote; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path, PathBuf}, +}; + +type Error = Box; + +#[derive(Clone)] +struct Schema { + local: BTreeMap, + registry: BTreeMap, + origins: BTreeMap, + dependencies: BTreeSet, +} + +/// A compiler invocation's schema root and resolved import graph. +pub struct SchemaLoader { + root: PathBuf, + schemas: BTreeMap, + visiting: Vec, +} + +impl SchemaLoader { + pub fn new(root: &Path) -> Self { + Self { + root: root.to_owned(), + schemas: BTreeMap::new(), + visiting: Vec::new(), + } + } + + /// Generate one module. `path` is relative to this loader's schema root. + pub fn generate(&mut self, path: &Path, _config: Config) -> Result { + let schema = self.resolve(path)?; + let mut generator = SchemaGenerator { + global_output: Vec::new(), + user_type_registry: schema.registry, + }; + for (name, definition) in &schema.local { + generator.gen_user_type(name, definition); + } + let mut imports = Vec::new(); + for dependency in schema.dependencies { + let name = module_name(&dependency)?; + let ident: syn::Ident = syn::parse_str(&name)?; + imports.push(quote! { + #[allow(unused_imports)] + use super::#ident::*; + }); + } + let definitions = generator.complete(); + Ok(quote! { #(#imports)* #definitions }) + } + + fn resolve(&mut self, path: &Path) -> Result { + if path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + || path.extension().and_then(|extension| extension.to_str()) != Some("bare") + { + return Err(format!( + "schema path must be a root-relative .bare file: {}", + path.display() + ) + .into()); + } + if let Some(schema) = self.schemas.get(path) { + return Ok(schema.clone()); + } + if let Some(start) = self.visiting.iter().position(|entry| entry == path) { + let cycle = self.visiting[start..] + .iter() + .map(PathBuf::as_path) + .chain(std::iter::once(path)) + .map(|entry| entry.display().to_string()) + .collect::>() + .join(" -> "); + return Err(format!("schema import cycle: {cycle}").into()); + } + self.visiting.push(path.to_owned()); + let result = self.load(path); + self.visiting.pop(); + if let Ok(schema) = &result { + self.schemas.insert(path.to_owned(), schema.clone()); + } + result + } + + fn load(&mut self, path: &Path) -> Result { + let source = fs::read_to_string(self.root.join(path)) + .map_err(|error| format!("cannot read schema {}: {error}", path.display()))?; + let imports = + parser::imports(&source).map_err(|error| format!("{}: {error}", path.display()))?; + let mut registry = BTreeMap::new(); + let mut origins: BTreeMap = BTreeMap::new(); + let mut dependencies = BTreeSet::new(); + for import in imports { + let imported_path = PathBuf::from(&import); + let imported = self + .resolve(&imported_path) + .map_err(|error| format!("{} imports {import}: {error}", path.display()))?; + for (name, origin) in imported.origins { + if let Some(previous) = origins.get(&name) { + if previous != &origin { + return Err(format!( + "{} imports conflicting type {name} from {} and {}", + path.display(), + previous.display(), + origin.display() + ) + .into()); + } + } + origins.insert(name, origin); + } + registry.extend(imported.registry); + dependencies.insert(imported_path); + dependencies.extend(imported.dependencies); + } + let local = parser::parse_string(&source, ®istry).map_err(|error| { + if let Some(origin) = error + .strip_prefix("duplicate or shadowed type ") + .and_then(|name| origins.get(name)) + { + format!( + "{}: {error}, imported from {}", + path.display(), + origin.display() + ) + } else { + format!("{}: {error}", path.display()) + } + })?; + for name in local.keys() { + origins.insert(name.clone(), path.to_owned()); + } + registry.extend(local.clone()); + Ok(Schema { + local, + registry, + origins, + dependencies, + }) + } +} + +/// Strip `.bare` and replace each directory separator and dot with `_`. +/// For example, `shared/example.types.v1.bare` becomes `shared_example_types_v1`. +pub fn module_name(path: &Path) -> Result { + let stem = path.with_extension(""); + let name = stem + .components() + .map(|part| { + part.as_os_str() + .to_str() + .map(|part| part.replace('.', "_")) + .ok_or_else(|| format!("schema path is not UTF-8: {}", path.display())) + }) + .collect::, _>>()? + .join("_"); + syn::parse_str::(&name) + .map_err(|error| format!("invalid module name {name} for {}: {error}", path.display()))?; + Ok(name) +} diff --git a/rust/vbare-gen/src/lib.rs b/rust/vbare-gen/src/lib.rs index 7ac11c7..2521b7b 100644 --- a/rust/vbare-gen/src/lib.rs +++ b/rust/vbare-gen/src/lib.rs @@ -93,14 +93,16 @@ byte arrays, such as `list`, are not annotated, because `serde_bytes` does */ -use std::{collections::BTreeMap, fs::read_to_string, path::Path}; +use std::{collections::BTreeMap, path::Path}; use heck::{ToSnakeCase, ToUpperCamelCase}; -use parser::{parse_string, AnyType, PrimitiveType, StructField}; +use parser::{AnyType, PrimitiveType, StructField}; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; +mod imports; mod parser; +pub use imports::{module_name, SchemaLoader}; /// Configuration for `bare_schema` code generation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -153,18 +155,12 @@ impl Caps { /// path is treated as relative to the file location of the macro's use. /// For details on how the BARE data model maps to the Rust data model, see the [`Serialize` /// derive macro's documentation.](https://docs.rs/serde_bare/latest/serde_bare/) -pub fn bare_schema(schema_path: &Path, _config: Config) -> proc_macro2::TokenStream { - let file = read_to_string(schema_path).unwrap(); - let mut schema_generator = SchemaGenerator { - global_output: Default::default(), - user_type_registry: parse_string(&file), - }; - - for (name, user_type) in &schema_generator.user_type_registry.clone() { - schema_generator.gen_user_type(&name, &user_type); - } - - schema_generator.complete() +pub fn bare_schema(schema_path: &Path, config: Config) -> proc_macro2::TokenStream { + let root = schema_path.parent().unwrap_or_else(|| Path::new(".")); + let name = schema_path.file_name().expect("schema file name"); + SchemaLoader::new(root) + .generate(Path::new(name), config) + .unwrap_or_else(|error| panic!("{error}")) } struct SchemaGenerator { diff --git a/rust/vbare-gen/src/parser.rs b/rust/vbare-gen/src/parser.rs index b324574..e589ae7 100644 --- a/rust/vbare-gen/src/parser.rs +++ b/rust/vbare-gen/src/parser.rs @@ -53,33 +53,59 @@ pub struct StructField { pub type_r: AnyType, } -pub fn parse_string(schema: &str) -> BTreeMap { - let schema = BARE::parse(Rule::schema, &schema) - .unwrap_or_else(|e| panic!("{}", e)) +pub fn imports(schema: &str) -> Result, String> { + let parsed = BARE::parse(Rule::schema, schema) + .map_err(|error| error.to_string())? .next() - .unwrap(); // this can't fail if parsing didn't fail - if schema.as_rule() != Rule::schema { - unreachable!() + .unwrap(); + let mut imports = Vec::new(); + for item in parsed + .into_inner() + .filter(|item| item.as_rule() == Rule::import) + { + let line_start = schema[..item.as_span().start()] + .rfind('\n') + .map_or(0, |i| i + 1); + if !schema[line_start..item.as_span().start()].trim().is_empty() { + return Err("#import must be on its own line".into()); + } + imports.push(item.into_inner().next().unwrap().as_str().to_owned()); } - let mut user_type_registry: BTreeMap = BTreeMap::default(); - for user_types in schema.into_inner() { - if user_types.as_rule() == Rule::EOI { - break; + Ok(imports) +} + +pub fn parse_string( + schema: &str, + imported: &BTreeMap, +) -> Result, String> { + let schema = BARE::parse(Rule::schema, schema) + .map_err(|error| error.to_string())? + .next() + .unwrap(); + let mut registry = imported.clone(); + let mut local = BTreeMap::new(); + for item in schema.into_inner() { + if item.as_rule() != Rule::user_type { + continue; } - let mut inner = user_types.into_inner(); - let user_type_name = inner.next().unwrap(); - let user_type_type = inner.next().unwrap(); - if user_type_registry.contains_key(user_type_name.as_str()) { - panic!("Duplicate definition: {:?}", user_type_name.as_span()) + let mut inner = item.into_inner(); + let name = inner.next().unwrap().as_str(); + let definition = inner.next().unwrap(); + if registry.contains_key(name) { + return Err(format!("duplicate or shadowed type {name}")); } - let t = parse_any_type(&user_type_registry, user_type_type); - user_type_registry.insert(user_type_name.as_str().into(), t); + let value = parse_any_type(®istry, definition)?; + registry.insert(name.to_owned(), value.clone()); + local.insert(name.to_owned(), value); } - user_type_registry + Ok(local) } -fn parse_any_type(registry: &BTreeMap, pair: Pair<'_, Rule>) -> AnyType { - match pair.as_rule() { +fn parse_any_type( + registry: &BTreeMap, + pair: Pair<'_, Rule>, +) -> Result { + Ok(match pair.as_rule() { Rule::unsigned_t => parse_unsigned_int(pair), Rule::signed_t => parse_signed_int(pair), Rule::void_t => AnyType::Primitive(PrimitiveType::Void), @@ -96,24 +122,24 @@ fn parse_any_type(registry: &BTreeMap, pair: Pair<'_, Rule>) -> } } Rule::enum_t => parse_enum(pair), - Rule::list_t => parse_list(registry, pair), - Rule::struct_t => parse_struct(registry, pair), - Rule::map_t => parse_map(registry, pair), - Rule::union_t => parse_union(registry, pair), + Rule::list_t => parse_list(registry, pair)?, + Rule::struct_t => parse_struct(registry, pair)?, + Rule::map_t => parse_map(registry, pair)?, + Rule::union_t => parse_union(registry, pair)?, Rule::optional_t => { let inner_type = pair.into_inner().next().unwrap(); - AnyType::Optional(Box::new(parse_any_type(registry, inner_type))) + AnyType::Optional(Box::new(parse_any_type(registry, inner_type)?)) } Rule::user_type_name => { let user_type = pair.as_str(); if registry.contains_key(user_type) { AnyType::TypeReference(user_type.into()) } else { - panic!("User type {user_type} has not been defined yet."); + return Err(format!("user type {user_type} has not been defined yet")); } } x => panic!("Unreachable: {x:?}"), - } + }) } fn parse_unsigned_int(pair: Pair<'_, Rule>) -> AnyType { @@ -152,53 +178,65 @@ fn parse_enum(pair: Pair<'_, Rule>) -> AnyType { AnyType::Enum(members) } -fn parse_list(registry: &BTreeMap, pair: Pair<'_, Rule>) -> AnyType { +fn parse_list( + registry: &BTreeMap, + pair: Pair<'_, Rule>, +) -> Result { let mut list = pair.into_inner(); let list_type = list.next().unwrap(); - let inner = parse_any_type(registry, list_type); + let inner = parse_any_type(registry, list_type)?; let length: Option = list .next() .and_then(|e: Pair<'_, Rule>| Some(e.as_str().parse().unwrap())); - AnyType::List { + Ok(AnyType::List { inner: Box::new(inner), length, - } + }) } -fn parse_struct(registry: &BTreeMap, pair: Pair<'_, Rule>) -> AnyType { +fn parse_struct( + registry: &BTreeMap, + pair: Pair<'_, Rule>, +) -> Result { let mut st = pair.into_inner(); let mut fields: Vec = Vec::new(); while let Some(struct_t) = st.next() { let mut struct_field = struct_t.into_inner(); let field_name = struct_field.next().unwrap(); let field_type = struct_field.next().unwrap(); - let ft = parse_any_type(registry, field_type); + let ft = parse_any_type(registry, field_type)?; fields.push(StructField { name: field_name.as_str().to_string(), type_r: ft, }) } - AnyType::Struct(fields) + Ok(AnyType::Struct(fields)) } -fn parse_map(registry: &BTreeMap, pair: Pair<'_, Rule>) -> AnyType { +fn parse_map( + registry: &BTreeMap, + pair: Pair<'_, Rule>, +) -> Result { let mut map = pair.into_inner(); let key_t = map.next().unwrap(); let value_t = map.next().unwrap(); - let key = parse_any_type(registry, key_t); - let value = parse_any_type(registry, value_t); - AnyType::Map { + let key = parse_any_type(registry, key_t)?; + let value = parse_any_type(registry, value_t)?; + Ok(AnyType::Map { key: Box::new(key), value: Box::new(value), - } + }) } -fn parse_union(registry: &BTreeMap, pair: Pair<'_, Rule>) -> AnyType { +fn parse_union( + registry: &BTreeMap, + pair: Pair<'_, Rule>, +) -> Result { let union_t = pair.into_inner(); let mut members: Vec = Vec::new(); for union_member in union_t { - let t = parse_any_type(registry, union_member); + let t = parse_any_type(registry, union_member)?; members.push(t); } - AnyType::Union(members) + Ok(AnyType::Union(members)) } fn parse_float(pair: Pair<'_, Rule>) -> AnyType {