Skip to content

Commit e5e1c51

Browse files
axelvclaude
andcommitted
feat: add wasm-bindgen target (closes beda-software#13)
Add a `wasm` feature flag that compiles the FHIRPath parser and SDC expression analyzer to WebAssembly via wasm-bindgen. The WASM bindings expose parse(), annotate_expression(), analyze_expression(), and QuestionnaireIndex to JavaScript consumers. - Add wasm-bindgen and serde-wasm-bindgen as optional dependencies - Add Serialize derives to AST and analysis types for JS interop - Add serde rename attributes for snake_case/tagged enum serialization - Add hand-written TypeScript type definitions in wasm/ - Add CI job to build WASM and upload artifacts - Add rlib to crate-type to support both cdylib and test builds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 539f741 commit e5e1c51

10 files changed

Lines changed: 301 additions & 11 deletions

File tree

.github/workflows/build.yaml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ jobs:
2828
run: uv python install ${{ matrix.python-version }}
2929
- name: Install dependencies
3030
run: uv sync --extra test
31-
- name: Run tests
31+
- name: Run Rust tests
32+
run: cargo test --no-default-features
33+
- name: Run Python tests
3234
run: uv run pytest
3335
- name: Upload coverage to Codecov
3436
uses: codecov/codecov-action@v4
@@ -40,3 +42,25 @@ jobs:
4042
flags: unittests
4143
name: codecov-umbrella
4244
verbose: true
45+
46+
wasm:
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
- name: Install Rust toolchain
51+
uses: dtolnay/rust-toolchain@stable
52+
with:
53+
targets: wasm32-unknown-unknown
54+
- name: Install wasm-pack
55+
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
56+
- name: Build WASM
57+
run: wasm-pack build --target web --features wasm --no-default-features
58+
- name: Overlay custom package metadata and types
59+
run: |
60+
cp wasm/package.json pkg/package.json
61+
cp wasm/fhirpath_wasm.d.ts pkg/fhirpath_wasm.d.ts
62+
- name: Upload WASM artifacts
63+
uses: actions/upload-artifact@v4
64+
with:
65+
name: wasm-pkg
66+
path: pkg/

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ docs/_build/
7474
# Rust / Cargo build artifacts
7575
target/
7676

77+
# wasm-pack build output
78+
pkg/
79+
7780
# Jupyter Notebook
7881
.ipynb_checkpoints
7982

Cargo.lock

Lines changed: 80 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ edition = "2021"
55

66
[lib]
77
name = "_rust"
8-
crate-type = ["cdylib"]
8+
crate-type = ["cdylib", "rlib"]
99

1010
[features]
1111
default = ["python"]
1212
python = ["dep:pyo3"]
13+
wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen"]
1314

1415
[dependencies]
1516
pyo3 = { version = "0.25", features = ["extension-module"], optional = true }
1617
serde = { version = "1", features = ["derive"] }
1718
serde_json = "1"
19+
wasm-bindgen = { version = "0.2", optional = true }
20+
serde-wasm-bindgen = { version = "0.6", optional = true }

src/analyze/mod.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
77
// ── Core types ──────────────────────────────────────────────────────────
88

9-
#[derive(Debug, Clone, PartialEq)]
9+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
1010
pub struct Span {
1111
pub start: usize,
1212
pub end: usize,
1313
}
1414

15-
#[derive(Debug, Clone, PartialEq)]
15+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
16+
#[serde(rename_all = "snake_case")]
1617
pub enum ValueAccessor {
1718
/// `.answer.value` (bare)
1819
Value,
@@ -22,7 +23,8 @@ pub enum ValueAccessor {
2223
Display,
2324
}
2425

25-
#[derive(Debug, Clone, PartialEq)]
26+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
27+
#[serde(tag = "type", rename_all = "snake_case")]
2628
pub enum AnnotationKind {
2729
/// Path navigating to a QR answer value.
2830
AnswerReference {
@@ -39,19 +41,21 @@ pub enum AnnotationKind {
3941
},
4042
}
4143

42-
#[derive(Debug, Clone, PartialEq)]
44+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
4345
pub struct Annotation {
4446
pub span: Span,
4547
pub kind: AnnotationKind,
4648
}
4749

48-
#[derive(Debug, Clone, PartialEq)]
50+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
51+
#[serde(rename_all = "snake_case")]
4952
pub enum Severity {
5053
Error,
5154
Warning,
5255
}
5356

54-
#[derive(Debug, Clone, PartialEq)]
57+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
58+
#[serde(rename_all = "snake_case")]
5559
pub enum DiagnosticCode {
5660
UnknownLinkId,
5761
UnreachableLinkId,
@@ -61,7 +65,7 @@ pub enum DiagnosticCode {
6165
ContextUnreachableFromParent,
6266
}
6367

64-
#[derive(Debug, Clone, PartialEq)]
68+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
6569
pub struct Diagnostic {
6670
pub span: Span,
6771
pub severity: Severity,
@@ -86,7 +90,7 @@ pub struct AnalysisContext {
8690
}
8791

8892
/// Result of analyzing a FHIRPath expression.
89-
#[derive(Debug, Clone, PartialEq)]
93+
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
9094
pub struct ExpressionAnalysis {
9195
pub annotations: Vec<Annotation>,
9296
pub diagnostics: Vec<Diagnostic>,

src/bindings/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
#[cfg(feature = "python")]
22
pub mod python;
3+
4+
#[cfg(feature = "wasm")]
5+
pub mod wasm;

src/bindings/wasm.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#![cfg(feature = "wasm")]
2+
3+
use wasm_bindgen::prelude::*;
4+
5+
use crate::analyze;
6+
7+
/// Parse a FHIRPath expression string into an AST.
8+
///
9+
/// Returns the AST as a JavaScript object.
10+
#[wasm_bindgen]
11+
pub fn parse(expr: &str) -> Result<JsValue, JsError> {
12+
let ast = crate::parse(expr).map_err(|e| JsError::new(&e.0))?;
13+
serde_wasm_bindgen::to_value(&ast).map_err(|e| JsError::new(&e.to_string()))
14+
}
15+
16+
/// Annotate a FHIRPath expression, extracting answer references,
17+
/// item references, and coded values.
18+
///
19+
/// Returns `Annotation[]` as a JavaScript value.
20+
#[wasm_bindgen]
21+
pub fn annotate_expression(expr: &str) -> Result<JsValue, JsError> {
22+
let annotations =
23+
analyze::annotate_expression(expr).map_err(|e| JsError::new(&e.to_string()))?;
24+
serde_wasm_bindgen::to_value(&annotations).map_err(|e| JsError::new(&e.to_string()))
25+
}
26+
27+
/// A Questionnaire index for use in expression analysis.
28+
///
29+
/// Build one from a FHIR Questionnaire JSON string, then pass it
30+
/// to `analyze_expression` for semantic validation.
31+
#[wasm_bindgen]
32+
pub struct QuestionnaireIndex {
33+
inner: analyze::QuestionnaireIndex,
34+
}
35+
36+
#[wasm_bindgen]
37+
impl QuestionnaireIndex {
38+
/// Build a `QuestionnaireIndex` from a FHIR Questionnaire JSON string.
39+
#[wasm_bindgen(constructor)]
40+
pub fn new(questionnaire_json: &str) -> Result<QuestionnaireIndex, JsError> {
41+
let value: serde_json::Value = serde_json::from_str(questionnaire_json)
42+
.map_err(|e| JsError::new(&format!("Invalid JSON: {e}")))?;
43+
Ok(QuestionnaireIndex {
44+
inner: analyze::QuestionnaireIndex::build(&value),
45+
})
46+
}
47+
}
48+
49+
/// Analyze a FHIRPath expression in the context of a Questionnaire.
50+
///
51+
/// Returns `{ annotations: Annotation[], diagnostics: Diagnostic[] }`.
52+
///
53+
/// - `expr` -- the FHIRPath expression string
54+
/// - `index` -- a `QuestionnaireIndex` built from the Questionnaire
55+
/// - `scope_link_id` -- optional linkId of the item scope (for reachability checks)
56+
/// - `parent_context_expr` -- optional parent context expression (raw FHIRPath)
57+
#[wasm_bindgen]
58+
pub fn analyze_expression(
59+
expr: &str,
60+
index: &QuestionnaireIndex,
61+
scope_link_id: Option<String>,
62+
parent_context_expr: Option<String>,
63+
) -> Result<JsValue, JsError> {
64+
let context = analyze::AnalysisContext {
65+
scope_link_id,
66+
parent_context_expr,
67+
};
68+
let result = analyze::analyze_expression(expr, &index.inner, &context)
69+
.map_err(|e| JsError::new(&e.to_string()))?;
70+
serde_wasm_bindgen::to_value(&result).map_err(|e| JsError::new(&e.to_string()))
71+
}

src/parser.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use crate::lexer::{Token, TokenKind};
44

5-
#[derive(Debug, Clone)]
5+
#[derive(Debug, Clone, serde::Serialize)]
66
pub struct AstNode {
77
pub node_type: &'static str,
88
pub terminal_node_text: Vec<String>,

0 commit comments

Comments
 (0)