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
8 changes: 8 additions & 0 deletions crates/jedem-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,13 +412,21 @@ fn export_name_of(attrs: &[syn::Attribute]) -> syn::Result<Option<String>> {

/// `Result<T, E>` -> `T`. Matched by name because a proc macro sees tokens,
/// not resolved types; an aliased `Result` is the known cost of that.
///
/// The error type is deliberately not inspected. Every backend renders failure
/// as that language's own mechanism -- a raised exception, a thrown `Error` --
/// carrying the error's `Display` text, so **anything that implements `Display`
/// works**, including `Box<dyn Error>` and `anyhow::Error`. A `Result` with a
/// single elided parameter (`Result<T>`, from a crate's own alias) is accepted
/// too.
fn unwrap_result(t: &Type) -> Option<&Type> {
let Type::Path(p) = t else { return None };
let seg = p.path.segments.last()?;
if seg.ident != "Result" {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
// `Result` with no parameters is not a result we can lower.
return None;
};
args.args.first().and_then(|a| match a {
Expand Down
61 changes: 56 additions & 5 deletions crates/jedem/src/gen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,36 @@ pub fn generate(surface: &Surface, target: Target, core_path: &str) -> String {
Target::Python => python::generate(surface, core_path),
Target::Node => node::generate(surface, core_path),
};
// Exactly one terminal newline, for every backend. Generated files are
// committed and diffed against a fresh generation, so if `cargo fmt`
// rewrites the tail then every `cargo fmt` breaks the build -- and each
// backend getting this right independently is a bug waiting to recur.
let mut out = body.trim_end().to_string();
normalise(&body)
}

/// Make generated output rustfmt-stable, centrally.
///
/// Generated files are committed and diffed against a fresh generation, so
/// anything `cargo fmt` rewrites breaks every build that runs it. Each backend
/// getting this right independently is a bug waiting to recur -- and it has
/// recurred, three times: a trailing space on an empty doc line, a trailing
/// blank line at end of file, and a double blank line between interfaces.
///
/// So the invariants live here rather than in any backend: no trailing
/// whitespace, no run of blank lines, exactly one terminal newline.
fn normalise(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let mut blank_run = 0usize;
for line in body.lines() {
let line = line.trim_end();
if line.is_empty() {
blank_run += 1;
if blank_run > 1 {
continue;
}
} else {
blank_run = 0;
}
out.push_str(line);
out.push('\n');
}
let mut out = out.trim_end().to_string();
out.push('\n');
out
}
Expand Down Expand Up @@ -357,6 +382,32 @@ mod format_stability {
fn no_tabs() {
each_target(|t, out| assert!(!out.contains('\t'), "{t:?} contains a tab"));
}

/// rustfmt collapses consecutive blank lines, so emitting them means every
/// `cargo fmt` rewrites the file and breaks the drift guard. This is the
/// third distinct way that has happened; the check is now structural.
#[test]
fn no_run_of_blank_lines() {
each_target(|t, out| {
let mut blank = 0;
for (i, line) in out.lines().enumerate() {
blank = if line.trim().is_empty() { blank + 1 } else { 0 };
assert!(blank < 2, "{t:?} has consecutive blank lines at {}", i + 1);
}
});
}

/// The whole point: what jedem writes is what rustfmt would leave alone.
#[test]
fn output_is_already_normalised() {
each_target(|t, out| {
assert_eq!(
super::normalise(out),
out,
"{t:?} is not normalisation-stable"
)
});
}
}

#[cfg(test)]
Expand Down
32 changes: 31 additions & 1 deletion demo/hello/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,34 @@ impl Hello {
}
}

jedem::surface! { name: "hello", version: "0.1.0", api: [Hello] }
jedem::surface! { name: "hello", version: "0.1.0", api: [Hello, fallible] }

/// Errors that can come from more than one place.
///
/// Before `Box<dyn Error>` was accepted, a function that could fail two ways
/// had to flatten to `Result<_, String>` and litter itself with
/// `.map_err(|e| e.to_string())`. jedem never inspected the error type — every
/// backend renders failure as that language's own mechanism carrying the
/// error's `Display` text — so anything `Display` works.
#[jedem::export]
pub mod fallible {
use std::error::Error;

/// Parse a number, then halve it. Two different failure types, one
/// signature, no `map_err` in sight.
pub fn halve_parsed(text: &str) -> Result<i64, Box<dyn Error>> {
let n: i64 = text.parse()?;
if n % 2 != 0 {
return Err(format!("{n} is odd").into());
}
Ok(n / 2)
}

/// A plain concrete error still works, unchanged.
pub fn checked(text: &str) -> Result<String, super::EmptyName> {
if text.is_empty() {
return Err(super::EmptyName);
}
Ok(text.to_string())
}
}
39 changes: 39 additions & 0 deletions demo/hello/tests/export_forms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,42 @@ fn all_three_forms_generate() {
assert!(py.contains("core::arithmetic::add(a, b)"), "a mod does");
assert!(py.contains("core::Hello::greet(name)"), "a type does");
}

/// Any error type works, because jedem never inspects one.
///
/// Every backend renders failure as that language's own mechanism -- a raised
/// exception, a thrown `Error` -- carrying the error's `Display` text. So a
/// function that can fail two ways needs no unifying error enum and no
/// `.map_err(|e| e.to_string())`; `Box<dyn Error>` is enough.
#[test]
fn any_display_error_lowers_as_fallible() {
let ops = hello::fallible::JEDEM_INTERFACE.ops;
let by = |n: &str| ops.iter().find(|o| o.name == n).unwrap();

// Box<dyn Error> -- two failure types behind one signature.
let boxed = by("halve_parsed");
assert!(boxed.fallible);
assert_eq!(boxed.returns, jedem::Type::I64, "the Result is unwrapped");

// A concrete error type is unchanged.
let concrete = by("checked");
assert!(concrete.fallible);
assert_eq!(concrete.returns, jedem::Type::Str);
}

#[test]
fn a_boxed_error_generates_the_same_seam_as_a_concrete_one() {
const SURFACE: jedem::Surface = jedem::Surface {
name: "e",
version: "0.0.0",
interfaces: &[hello::fallible::JEDEM_INTERFACE],
};
for (target, seam) in [
(jedem::Target::Python, "PyResult<i64>"),
(jedem::Target::Node, "napi::Result<i64>"),
] {
let out = jedem::generate(&SURFACE, target, "core");
assert!(out.contains(seam), "{target:?} should raise: {out}");
assert!(out.contains("map_err(err)"), "{target:?}");
}
}
5 changes: 4 additions & 1 deletion demo/hello/tests/rust_still_works.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ fn exported_functions_are_ordinary_rust() {
fn the_descriptor_describes_what_was_written() {
assert_eq!(JEDEM_SURFACE.name, "hello");
assert_eq!(JEDEM_SURFACE.version, "0.1.0");
assert_eq!(JEDEM_SURFACE.interfaces.len(), 1);
// A type and a module, to prove `api:` takes both.
assert_eq!(JEDEM_SURFACE.interfaces.len(), 2);
let names: Vec<&str> = JEDEM_SURFACE.interfaces.iter().map(|i| i.name).collect();
assert_eq!(names, ["Hello", "fallible"]);

let iface = JEDEM_SURFACE.interfaces[0];
assert_eq!(iface.name, "Hello");
Expand Down
15 changes: 15 additions & 0 deletions demo/node/src/generated.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions demo/python/src/generated.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions demo/python/test.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading