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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SharedKey>
}
```

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/)
Expand Down
3 changes: 3 additions & 0 deletions fixtures/tests/imports/conflict/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#import "b.v1.bare"
#import "c.v1.bare"
type A struct { value: Shared }
1 change: 1 addition & 0 deletions fixtures/tests/imports/conflict/b.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type Shared u64
1 change: 1 addition & 0 deletions fixtures/tests/imports/conflict/c.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type Shared str
2 changes: 2 additions & 0 deletions fixtures/tests/imports/cycle/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "b.v1.bare"
type A u64
2 changes: 2 additions & 0 deletions fixtures/tests/imports/cycle/b.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "a.v1.bare"
type B u64
2 changes: 2 additions & 0 deletions fixtures/tests/imports/escape/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "../valid/simple.v1.bare"
type A u64
2 changes: 2 additions & 0 deletions fixtures/tests/imports/late/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
type A u64
#import "b.v1.bare"
1 change: 1 addition & 0 deletions fixtures/tests/imports/late/b.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type B u64
2 changes: 2 additions & 0 deletions fixtures/tests/imports/malformed/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import b.v1.bare
type A u64
2 changes: 2 additions & 0 deletions fixtures/tests/imports/missing/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "shared/missing.v1.bare"
type A u64
1 change: 1 addition & 0 deletions fixtures/tests/imports/module-conflict/shared/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type A u64
1 change: 1 addition & 0 deletions fixtures/tests/imports/module-conflict/shared_a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type B u64
2 changes: 2 additions & 0 deletions fixtures/tests/imports/shadow/a.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "b.v1.bare"
type Shared str
1 change: 1 addition & 0 deletions fixtures/tests/imports/shadow/b.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type Shared u64
3 changes: 3 additions & 0 deletions fixtures/tests/imports/valid/diamond.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#import "shared/middle.v2.bare"
#import "shared/common.v1.bare"
type Diamond struct { key: SharedKey }
10 changes: 10 additions & 0 deletions fixtures/tests/imports/valid/nested/transitive.v3.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#import "shared/middle.v2.bare"
type Choice union { SharedKey | Fraction | Empty | Payload }
type Message struct {
middle: Middle
key: optional<SharedKey>
keys: list<SharedKey>
fraction: Fraction
payload: Payload
choice: Choice
}
2 changes: 2 additions & 0 deletions fixtures/tests/imports/valid/pinned.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "shared/common.v7.bare"
type Pinned struct { key: SharedKey }
4 changes: 4 additions & 0 deletions fixtures/tests/imports/valid/shared/common.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type SharedKey struct { value: u64 }
type Fraction struct { value: f64 }
type Payload data
type Empty void
1 change: 1 addition & 0 deletions fixtures/tests/imports/valid/shared/common.v7.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type SharedKey struct { value: str }
2 changes: 2 additions & 0 deletions fixtures/tests/imports/valid/shared/middle.v2.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "shared/common.v1.bare"
type Middle struct { key: SharedKey }
2 changes: 2 additions & 0 deletions fixtures/tests/imports/valid/simple.v1.bare
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#import "shared/common.v1.bare"
type Simple struct { key: SharedKey }
1 change: 1 addition & 0 deletions rust/examples/basic/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
8 changes: 8 additions & 0 deletions rust/examples/basic/build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
use std::path::Path;

fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
Expand Down
38 changes: 38 additions & 0 deletions rust/examples/basic/tests/imports.rs
Original file line number Diff line number Diff line change
@@ -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::<Choice>(&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");
}
102 changes: 66 additions & 36 deletions rust/vbare-compiler/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -29,54 +33,80 @@ pub fn process_schemas_with_config(
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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::<Result<Vec<_>, _>>()?
.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<PathBuf>) -> 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(())
}
66 changes: 66 additions & 0 deletions rust/vbare-compiler/tests/imports.rs
Original file line number Diff line number Diff line change
@@ -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<SharedKey>"));
assert!(code.contains("pub keys: Vec<SharedKey>"));
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:?}"
);
}
}
}
7 changes: 4 additions & 3 deletions rust/vbare-gen/src/grammar.pest
Original file line number Diff line number Diff line change
@@ -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 | "_" | "-")* }
Expand Down Expand Up @@ -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) }
Loading
Loading