Skip to content
Open
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
393 changes: 393 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[workspace]
members = ["firm_core", "firm_lang", "firm_mcp", "firm_cli"]
members = ["firm_core", "firm_lang", "firm_mcp", "firm_cli", "firm_ffi"]
resolver = "3"
30 changes: 28 additions & 2 deletions docs/src/library/architecture.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# Architecture

Firm is organized as a Rust workspace with three crates, each with a specific responsibility.
Firm is organized as a Rust workspace with five crates, each with a specific responsibility.

## Crate overview

```
firm/
├── firm_core/ - Core data structures and graph operations
├── firm_lang/ - DSL parsing and generation
└── firm_cli/ - Command-line interface
├── firm_cli/ - Command-line interface
├── firm_mcp/ - MCP server for AI assistants
└── firm_ffi/ - UniFFI bindings for embedding in other apps
```

## firm_core
Expand Down Expand Up @@ -92,3 +94,27 @@ firm init
firm add --type person --id john
firm query 'from person | where name == "John"'
```

## firm_mcp

MCP (Model Context Protocol) server exposing workspace operations as tools for AI assistants.

**Responsibilities:**
- Expose query, entity access, and source operations as MCP tools
- Schema-aware entity creation from JSON
- Source file read/write/search operations

## firm_ffi

UniFFI-based foreign function interface for embedding Firm in other apps.

**Responsibilities:**
- Exposes `FirmSession` as an opaque handle for loading sources, building, and querying
- Typed FFI representations of entities, fields, and query results
- Bidirectional conversion between FFI types and core types

**Key types:**
- `FirmSession` - Opaque session managing a workspace and entity graph
- `FirmEntity` - Entity with typed fields
- `FirmFieldValue` - Enum mirroring `FieldValue` with FFI-safe types
- `FirmQueryResult` - Query result (entities or aggregation)
26 changes: 26 additions & 0 deletions firm_ffi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "firm_ffi"
version = "0.5.0"
edition = "2024"
license = "AGPL-3.0"

[lib]
crate-type = ["staticlib", "cdylib", "lib"]

[dependencies]
firm_core = { path = "../firm_core" }
firm_lang = { path = "../firm_lang" }
uniffi = { version = "0.31", features = ["cli"] }
thiserror = "2"
chrono = "0.4.43"
rust_decimal = "1.40.0"
iso_currency = "0.5.3"

[build-dependencies]
uniffi = { version = "0.31", features = ["build"] }

[dev-dependencies]

[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"
18 changes: 18 additions & 0 deletions firm_ffi/build-ios.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash
set -euo pipefail

# Build firm_ffi for iOS targets and generate Swift bindings

echo "Building for iOS device (aarch64-apple-ios)..."
cargo build -p firm_ffi --release --target aarch64-apple-ios

echo "Building for iOS simulator (aarch64-apple-ios-sim)..."
cargo build -p firm_ffi --release --target aarch64-apple-ios-sim

echo "Generating Swift bindings..."
cargo run -p firm_ffi --bin uniffi-bindgen generate \
--library target/release/libfirm_ffi.dylib \
--language swift \
--out-dir ./firm_ffi/generated

echo "Done. Swift bindings are in firm_ffi/generated/"
267 changes: 267 additions & 0 deletions firm_ffi/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
uniffi::setup_scaffolding!();

mod types;

pub use types::{
FirmDirection, FirmEntity, FirmField, FirmFieldValue, FirmQueryResult, FirmSchema,
FirmSchemaField,
};

use std::path::PathBuf;
use std::sync::Mutex;

use firm_core::graph::{EntityGraph, Query, QueryResult};
use firm_core::{Entity, EntityType, compose_entity_id};
use firm_lang::generate::generate_dsl;
use firm_lang::parser::query::parse_query;
use firm_lang::workspace::{Workspace, WorkspaceBuild};

/// Errors from the FFI layer.
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum FirmError {
#[error("Parse error: {message}")]
ParseError { message: String },
#[error("Build error: {message}")]
BuildError { message: String },
#[error("Query error: {message}")]
QueryError { message: String },
#[error("Not built: call build() first")]
NotBuilt,
#[error("{message}")]
Other { message: String },
}

/// Opaque handle to a Firm workspace with built graph.
#[derive(uniffi::Object)]
pub struct FirmSession {
inner: Mutex<SessionInner>,
}

struct SessionInner {
workspace: Workspace,
build: Option<WorkspaceBuild>,
graph: Option<EntityGraph>,
}

impl Default for FirmSession {
fn default() -> Self {
Self::new()
}
}

#[uniffi::export]
impl FirmSession {
/// Create a new empty session.
#[uniffi::constructor]
pub fn new() -> Self {
Self {
inner: Mutex::new(SessionInner {
workspace: Workspace::new(),
build: None,
graph: None,
}),
}
}

/// Load .firm source text with a virtual path.
pub fn load_source(&self, content: String, path: String) -> Result<(), FirmError> {
let mut inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
inner
.workspace
.load_string(content, PathBuf::from(&path))
.map_err(|e| FirmError::ParseError {
message: e.to_string(),
})?;
// Invalidate previous build
inner.build = None;
inner.graph = None;
Ok(())
}

/// Parse and validate all loaded sources, build entity graph.
pub fn build(&self) -> Result<(), FirmError> {
let mut inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let workspace_build =
inner
.workspace
.build()
.map_err(|e| FirmError::BuildError {
message: e.to_string(),
})?;

let mut graph = EntityGraph::new();
graph
.add_entities(workspace_build.entities.clone())
.map_err(|e| FirmError::BuildError {
message: format!("{:?}", e),
})?;
graph.build();

inner.build = Some(workspace_build);
inner.graph = Some(graph);
Ok(())
}

/// Execute a Firm query string.
pub fn query(&self, query_string: String) -> Result<FirmQueryResult, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let graph = inner.graph.as_ref().ok_or(FirmError::NotBuilt)?;

let parsed_query = parse_query(&query_string).map_err(|e| FirmError::QueryError {
message: format!("Failed to parse query: {}", e),
})?;

let query: Query = parsed_query.try_into().map_err(
|e: firm_lang::convert::to_query::QueryConversionError| FirmError::QueryError {
message: format!("Failed to convert query: {}", e),
},
)?;

let result = query.execute(graph).map_err(|e| FirmError::QueryError {
message: format!("Query execution failed: {}", e),
})?;

match result {
QueryResult::Entities(entities) => Ok(FirmQueryResult::Entities {
entities: entities.iter().map(|e| FirmEntity::from(*e)).collect(),
}),
QueryResult::Aggregation(agg) => Ok(FirmQueryResult::Aggregation {
value: agg.to_string(),
}),
}
}

/// Get an entity by type and ID.
pub fn get_entity(
&self,
entity_type: String,
id: String,
) -> Result<Option<FirmEntity>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let graph = inner.graph.as_ref().ok_or(FirmError::NotBuilt)?;

let composite_id = compose_entity_id(&entity_type, &id);
Ok(graph.get_entity(&composite_id).map(FirmEntity::from))
}

/// List entity IDs for a given type.
pub fn list_entities(&self, entity_type: String) -> Result<Vec<String>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let graph = inner.graph.as_ref().ok_or(FirmError::NotBuilt)?;

let et = EntityType::new(&entity_type);
let entities = graph.list_by_type(&et);
Ok(entities.iter().map(|e| e.id.to_string()).collect())
}

/// List all schema type names.
pub fn list_schemas(&self) -> Result<Vec<String>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let build = inner.build.as_ref().ok_or(FirmError::NotBuilt)?;

Ok(build
.schemas
.iter()
.map(|s| s.entity_type.to_string())
.collect())
}

/// Get related entities.
pub fn get_related(
&self,
entity_type: String,
id: String,
direction: FirmDirection,
) -> Result<Vec<FirmEntity>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let graph = inner.graph.as_ref().ok_or(FirmError::NotBuilt)?;

let composite_id = compose_entity_id(&entity_type, &id);
let dir = match direction {
FirmDirection::Outgoing => Some(firm_core::graph::Direction::Outgoing),
FirmDirection::Incoming => Some(firm_core::graph::Direction::Incoming),
FirmDirection::Both => None,
};

match graph.get_related(&composite_id, dir) {
Some(entities) => Ok(entities.iter().map(|e| FirmEntity::from(*e)).collect()),
None => Ok(vec![]),
}
}

/// Get the structured schema definition for an entity type.
pub fn get_schema(&self, entity_type: String) -> Result<Option<FirmSchema>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
let build = inner.build.as_ref().ok_or(FirmError::NotBuilt)?;
let et = EntityType::new(&entity_type);
Ok(build
.schemas
.iter()
.find(|s| s.entity_type == et)
.map(|s| {
let fields = s
.ordered_fields()
.iter()
.map(|(field_id, field_schema)| FirmSchemaField {
name: field_id.to_string(),
field_type: field_schema.expected_type().to_string(),
required: field_schema.is_required(),
allowed_values: field_schema.allowed_values().cloned(),
})
.collect();
FirmSchema {
entity_type: s.entity_type.to_string(),
fields,
}
}))
}

/// Generate .firm DSL text for an entity.
pub fn generate_entity_dsl(&self, entity: FirmEntity) -> Result<String, FirmError> {
let core_entity =
Entity::try_from(&entity).map_err(|e| FirmError::Other { message: e })?;
Ok(generate_dsl(&[core_entity]))
}

/// Find the virtual path containing a given entity.
pub fn find_entity_source(
&self,
entity_type: String,
id: String,
) -> Result<Option<String>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
Ok(inner
.workspace
.find_entity_source(&entity_type, &id)
.map(|p| p.to_string_lossy().into_owned()))
}

/// Get the loaded source content for a virtual path.
pub fn get_source(&self, path: String) -> Result<Option<String>, FirmError> {
let inner = self.inner.lock().map_err(|e| FirmError::Other {
message: e.to_string(),
})?;
Ok(inner
.workspace
.get_source(&PathBuf::from(&path))
.map(|s| s.to_string()))
}
}
Loading
Loading