Skip to content
Closed
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
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pharmsol-dsl = { path = "pharmsol-dsl", version = "0.28.2" }
pharmsol-macros = { path = "pharmsol-macros", version = "0.28.2" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
thiserror = "2.0.18"

[package]
name = "pharmsol"
Expand Down Expand Up @@ -52,7 +53,7 @@ cranelift-module = { version = "0.131.0", optional = true }
cranelift-native = { version = "0.131.0", optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = "2.0.18"
thiserror = { workspace = true }
tracing = "0.1.44"
wasm-encoder = { version = "0.247.0", optional = true }

Expand Down
1 change: 1 addition & 0 deletions pharmsol-dsl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ bench = false
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
57 changes: 37 additions & 20 deletions pharmsol-dsl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,11 @@ Do not use this crate for JIT compilation, native AoT export or load, WASM runti

## Main Pipeline

The public pipeline is:

1. `parse_model` or `parse_module`
2. `analyze_model` or `analyze_module`
3. `lower_typed_model` or `lower_typed_module`

The main public modules are:

- `ast` for syntax-level nodes
- `diagnostic` for spans, codes, and rendered reports
- `ir` for the typed intermediate representation
- `execution` for the lowered execution model shared by JIT, AoT, and WASM backends

The parser accepts both canonical `model { ... }` source and the authoring shorthand used by the `pharmsol` examples.

## Small Example
The one-shot pipeline is `compile_model` or `compile_module`, which fails with
the unified `DslError`:

```rust
use pharmsol_dsl::{analyze_model, lower_typed_model, parse_model};
use pharmsol_dsl::compile_model;

let source = r#"
name = bimodal_ke
Expand All @@ -47,15 +33,46 @@ dx(central) = -ke * central
out(cp) = central / v
"#;

let syntax = parse_model(source).expect("model parses");
let typed = analyze_model(&syntax).expect("model analyzes");
let execution = lower_typed_model(&typed).expect("model lowers");
let execution = compile_model(source).expect("model compiles");

assert_eq!(execution.name, "bimodal_ke");
assert_eq!(execution.metadata.routes.len(), 1);
assert_eq!(execution.metadata.outputs.len(), 1);
```

The staged pipeline is available when you need the intermediaterepresentations:

1. `parse_model` or `parse_module`
2. `analyze_model` or `analyze_module`
3. `lower_typed_model` or `lower_typed_module`

The main public modules are:

- `ast` for syntax-level nodes
- `diagnostic` for spans, codes, and rendered reports
- `ir` for the typed intermediate representation
- `execution` for the lowered execution model shared by JIT, AoT, and WASM backends

The parser accepts both canonical `model { ... }` source and the authoring
shorthand used by the `pharmsol` examples.

## Errors

Every stage reports errors with source spans and renders an annotated report
when printed:

```text
error[DSL2000]: unknown identifier `missing_state`
--> line 3, column 11
|
3 | out(cp) = missing_state
| ^^^^^^^^^^^^^ unknown identifier `missing_state`
```

`DslError::phase` identifies the failing stage, `DslError::diagnostics`
exposes the structured diagnostics, and `DslError::diagnostic_report` produces
a JSON-serializable report for editors and tooling.

## Boundary With `pharmsol`

`pharmsol-dsl` owns the frontend pipeline and its data structures.
Expand Down
38 changes: 29 additions & 9 deletions pharmsol-dsl/src/authoring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,8 @@ impl<'a> AuthoringParser<'a> {
let span = Span::new(line_offset + leading, line_offset + trailing);
self.note_span(span);

if find_top_level_arrow(trimmed).is_some() {
return self.parse_route_line(trimmed, span.start, span);
if let Some(arrow) = find_top_level_arrow(trimmed) {
return self.parse_route_line(trimmed, arrow, span.start, span);
}

let eq_index = find_top_level_assignment(trimmed).ok_or_else(|| {
Expand Down Expand Up @@ -435,7 +435,7 @@ impl<'a> AuthoringParser<'a> {
}

if let Some(name_segment) = lhs_trimmed.strip_prefix("const ") {
let name_abs = span.start + (lhs.find("const").unwrap() + "const ".len());
let name_abs = span.start + (lhs.len() - lhs_trimmed.len()) + "const ".len();
let name = parse_ident_segment(name_segment, name_abs)?;
let value = parse_expr_at(rhs, rhs_abs)?;
self.constants.push(Binding {
Expand Down Expand Up @@ -484,10 +484,10 @@ impl<'a> AuthoringParser<'a> {
fn parse_route_line(
&mut self,
trimmed: &str,
arrow: usize,
line_start: usize,
span: Span,
) -> Result<(), ParseError> {
let arrow = find_top_level_arrow(trimmed).unwrap();
let lhs = &trimmed[..arrow];
let rhs = &trimmed[arrow + 2..];
let call = parse_call_head(lhs, line_start)?
Expand Down Expand Up @@ -1168,15 +1168,32 @@ fn parse_expr_at(src: &str, abs_start: usize) -> Result<Expr, ParseError> {
}

fn parse_surface_rhs(src: &str, abs_start: usize) -> Result<SurfaceRhs, ParseError> {
parse_surface_rhs_at(src, abs_start, 0)
}

fn parse_surface_rhs_at(
src: &str,
abs_start: usize,
depth: usize,
) -> Result<SurfaceRhs, ParseError> {
let trimmed = src.trim_start();
let leading = src.len() - trimmed.len();
if starts_with_keyword(trimmed, "if") {
return parse_if_rhs(trimmed, abs_start + leading);
return parse_if_rhs(trimmed, abs_start + leading, depth);
}
Ok(SurfaceRhs::Expr(parse_expr_at(src, abs_start)?))
}

fn parse_if_rhs(src: &str, abs_start: usize) -> Result<SurfaceRhs, ParseError> {
fn parse_if_rhs(src: &str, abs_start: usize, depth: usize) -> Result<SurfaceRhs, ParseError> {
if depth >= crate::parser::MAX_NESTING_DEPTH {
return Err(ParseError::new(
format!(
"conditional expression is nested too deeply (maximum nesting depth is {})",
crate::parser::MAX_NESTING_DEPTH
),
Span::new(abs_start, abs_start + src.len().min(2)),
));
}
let rest = &src[2..];
let rest_leading = rest.len() - rest.trim_start().len();
let rest = &rest[rest_leading..];
Expand Down Expand Up @@ -1206,9 +1223,12 @@ fn parse_if_rhs(src: &str, abs_start: usize) -> Result<SurfaceRhs, ParseError> {
})?;

let condition = parse_expr_at(condition_src, rest_abs + 1)?;
let then_branch = parse_surface_rhs(&remaining[..else_index], remaining_abs)?;
let else_branch =
parse_surface_rhs(&remaining[else_index + 4..], remaining_abs + else_index + 4)?;
let then_branch = parse_surface_rhs_at(&remaining[..else_index], remaining_abs, depth + 1)?;
let else_branch = parse_surface_rhs_at(
&remaining[else_index + 4..],
remaining_abs + else_index + 4,
depth + 1,
)?;
let span = Span::new(abs_start, remaining_abs + remaining.len());

Ok(SurfaceRhs::If {
Expand Down
58 changes: 44 additions & 14 deletions pharmsol-dsl/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,19 @@ impl Diagnostic {
.to_string()
.len();
rendered.push_str(&format!("{:>width$} |\n", "", width = gutter));
let mut last_line = None;
for label in &self.labels {
rendered.push_str(&render_label(src, label, &self.message, gutter));
let label_line = line_info(src, label.span.start.min(src.len())).0;
// Repeat the source text only when the label moves to a new line.
let show_source = last_line != Some(label_line);
rendered.push_str(&render_label(
src,
label,
&self.message,
gutter,
show_source,
));
last_line = Some(label_line);
}
}
for note in &self.notes {
Expand Down Expand Up @@ -500,6 +511,15 @@ impl ParseError {
}
}

/// Marker propagated after a fatal parse error was recorded; carries no
/// diagnostics of its own and is always merged into a non-empty error.
pub(crate) fn aborted() -> Self {
Self {
diagnostics: Vec::new(),
source: None,
}
}

pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.diagnostics[0].notes.push(note.into());
self
Expand Down Expand Up @@ -580,14 +600,22 @@ impl fmt::Display for ParseError {
if let Some(source) = self.source() {
return f.write_str(&self.render(source));
}
let span = self.diagnostic().primary_span();
let diagnostic = self.diagnostic();
let span = diagnostic.primary_span();
write!(
f,
"{} at bytes {}..{}",
self.diagnostic().message,
span.start,
span.end
)
"error[{}]: {} (at bytes {}..{})",
diagnostic.code, diagnostic.message, span.start, span.end
)?;
let remaining = self.diagnostics.len() - 1;
if remaining > 0 {
write!(
f,
" (+{remaining} more error{})",
if remaining == 1 { "" } else { "s" }
)?;
}
Ok(())
}
}

Expand All @@ -606,10 +634,10 @@ fn render_label(
label: &DiagnosticLabel,
fallback_message: &str,
gutter: usize,
show_source: bool,
) -> String {
let offset = label.span.start.min(src.len());
let (line, _, line_start, line_end) = line_info(src, offset);
let line_text = &src[line_start..line_end];
let marker_start = src[line_start..offset].chars().count();
let highlight_end = label.span.end.min(line_end).max(offset);
let marker_len = src[offset..highlight_end].chars().count().max(1);
Expand All @@ -624,12 +652,14 @@ fn render_label(
});

let mut rendered = String::new();
rendered.push_str(&format!(
"{:>width$} | {}\n",
line,
line_text,
width = gutter
));
if show_source {
rendered.push_str(&format!(
"{:>width$} | {}\n",
line,
&src[line_start..line_end],
width = gutter
));
}
rendered.push_str(&format!(
"{:>width$} | {}{}",
"",
Expand Down
47 changes: 25 additions & 22 deletions pharmsol-dsl/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,8 @@ impl fmt::Display for LoweringError {
let span = self.diagnostic.primary_span();
write!(
f,
"{} at bytes {}..{}",
self.diagnostic.message, span.start, span.end
"error[{}]: {} (at bytes {}..{})",
self.diagnostic.code, self.diagnostic.message, span.start, span.end
)
}
}
Expand Down Expand Up @@ -530,7 +530,12 @@ impl<'a> ExecutionLowerer<'a> {
len,
span: state.span,
});
next_state_offset += len;
next_state_offset = next_state_offset.checked_add(len).ok_or_else(|| {
LoweringError::new(
"combined state sizes exceed the supported state space",
state.span,
)
})?;
}

let uses_authoring_route_kinds =
Expand All @@ -557,21 +562,21 @@ impl<'a> ExecutionLowerer<'a> {
.with_note("lag and bioavailability are bolus-only route properties"));
}
}
let index = if uses_authoring_route_kinds {
match route.kind.expect("authoring routes must preserve kind") {
RouteKind::Bolus => {
let index = next_bolus_index;
next_bolus_index += 1;
index
}
RouteKind::Infusion => {
let index = next_infusion_index;
next_infusion_index += 1;
index
}
// Authoring source always records a kind per route; canonical
// `model {}` source never does. Mixed models fall back to
// declaration order.
let index = match (uses_authoring_route_kinds, route.kind) {
(true, Some(RouteKind::Bolus)) => {
let index = next_bolus_index;
next_bolus_index += 1;
index
}
(true, Some(RouteKind::Infusion)) => {
let index = next_infusion_index;
next_infusion_index += 1;
index
}
} else {
declaration_index
_ => declaration_index,
};
route_slots.insert(route.symbol, index);
let destination =
Expand Down Expand Up @@ -1635,9 +1640,8 @@ out(cp) = central / v ~ continuous()

let model = crate::parse_model(src).expect("authoring model parses");
let typed = crate::analyze_model(&model).expect("authoring model analyzes");
let error = crate::lower_typed_model(&typed)
.err()
.expect("infusion lag should fail during lowering");
let error =
crate::lower_typed_model(&typed).expect_err("infusion lag should fail during lowering");

assert!(error
.to_string()
Expand All @@ -1664,8 +1668,7 @@ out(cp) = central / v ~ continuous()
let model = crate::parse_model(src).expect("authoring model parses");
let typed = crate::analyze_model(&model).expect("authoring model analyzes");
let error = crate::lower_typed_model(&typed)
.err()
.expect("infusion bioavailability should fail during lowering");
.expect_err("infusion bioavailability should fail during lowering");

assert!(error
.to_string()
Expand Down
Loading
Loading