feat: implement the bDS2-compatible core Lua API
This commit is contained in:
1845
crates/bds-core/src/scripting/core_host.rs
Normal file
1845
crates/bds-core/src/scripting/core_host.rs
Normal file
File diff suppressed because it is too large
Load Diff
430
crates/bds-core/src/scripting/manifest.rs
Normal file
430
crates/bds-core/src/scripting/manifest.rs
Normal file
@@ -0,0 +1,430 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiManifest {
|
||||
pub version: String,
|
||||
pub root_methods: Vec<ApiMethod>,
|
||||
pub methods: Vec<ApiMethod>,
|
||||
pub types: Vec<ApiType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiMethod {
|
||||
#[serde(default = "bds2_compatibility")]
|
||||
pub compatibility: String,
|
||||
#[serde(default)]
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
pub params: Vec<ApiParameter>,
|
||||
pub returns: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiParameter {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_name: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiType {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub fields: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ApiManifest {
|
||||
pub fn namespaces(&self) -> Vec<&str> {
|
||||
self.methods
|
||||
.iter()
|
||||
.map(|method| method.namespace.as_str())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn render_reference(&self) -> String {
|
||||
let mut output = format!(
|
||||
"# RuDS Lua API Reference\n\nContract version: `{}`\n\nThe `bds` global is the supported bridge from sandboxed Lua scripts to RuDS. The unmarked method signatures are identical to bDS2 so the same published script file can run in either application. Calls are synchronous, JSON-compatible values cross the bridge as Lua tables, and project-scoped methods always use the active project.\n\n## Usage\n\nA utility script exposes `main(input)` and calls the API through `bds`:\n\n```lua\nfunction main(input)\n local posts = bds.posts.get_all()\n bds.app.log(\"Found \" .. #posts .. \" posts\")\n return posts\nend\n```\n\nMacro scripts expose `render(input, context)` and transform scripts expose `main(input, context)`. Complete runnable files are in [`examples/`](examples/). Scripts cannot access the network, filesystem, processes, environment variables, or native Lua modules directly; use the documented host methods instead. Host failures return `nil` or `false` where the signature permits it.\n\n## Conventions\n\n- A parameter ending in `?` is optional.\n- `T | nil` means the call can return no value. Check for `nil` before using it.\n- `T[]` is a one-based Lua array.\n- Public records are documented in [Lua API Types](TYPES.md).\n- Dates and timestamps returned by RuDS records are ISO-8601 strings.\n- Methods marked **RuDS extension** are not available to scripts running under bDS2.\n\n## Contents\n",
|
||||
self.version
|
||||
);
|
||||
|
||||
if !self.root_methods.is_empty() {
|
||||
output.push_str("\n- [Root helpers](#root-helpers)");
|
||||
}
|
||||
for namespace in self.namespaces() {
|
||||
output.push_str(&format!("\n- [`bds.{namespace}`](#bds{namespace})"));
|
||||
}
|
||||
output.push_str("\n- [Public data types](TYPES.md)\n");
|
||||
|
||||
if !self.root_methods.is_empty() {
|
||||
output.push_str("\n## Root helpers\n");
|
||||
for method in &self.root_methods {
|
||||
self.render_method(&mut output, method);
|
||||
}
|
||||
}
|
||||
|
||||
for namespace in self.namespaces() {
|
||||
output.push_str(&format!("\n## `bds.{namespace}`\n"));
|
||||
for method in self
|
||||
.methods
|
||||
.iter()
|
||||
.filter(|method| method.namespace == namespace)
|
||||
{
|
||||
self.render_method(&mut output, method);
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn render_types(&self) -> String {
|
||||
let mut output = format!(
|
||||
"# Lua API Types\n\nContract version: `{}`\n\nThese are the public, JSON-compatible records returned by the Lua host API. They contain no database handles or private implementation fields.\n\n## Value conventions\n\n- `T | nil` marks an optional field.\n- `T[]` is a one-based Lua array.\n- `table` is a JSON-compatible Lua table whose shape depends on the operation.\n- ISO-8601 values are strings such as `2026-07-19T08:00:00Z`.\n\n## Contents\n",
|
||||
self.version
|
||||
);
|
||||
for api_type in &self.types {
|
||||
output.push_str(&format!(
|
||||
"\n- [`{}`](#{})",
|
||||
api_type.name,
|
||||
api_type.name.to_lowercase()
|
||||
));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
for api_type in &self.types {
|
||||
output.push_str(&format!(
|
||||
"\n## `{}`\n\n{}\n\n**Lua shape**\n\n```lua\n{}\n```\n\n| Field | Type | Required | Meaning |\n| --- | --- | --- | --- |\n",
|
||||
api_type.name,
|
||||
api_type.description,
|
||||
self.example_for_type(api_type)
|
||||
));
|
||||
for (field, type_name) in &api_type.fields {
|
||||
let type_name = type_name.as_str().unwrap_or("any");
|
||||
output.push_str(&format!(
|
||||
"| `{field}` | `{}` | {} | {} |\n",
|
||||
type_name.replace('|', "\\|"),
|
||||
if type_name.contains("nil") {
|
||||
"No"
|
||||
} else {
|
||||
"Yes"
|
||||
},
|
||||
field_description(field)
|
||||
));
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
pub fn render_completions(&self) -> String {
|
||||
let completions = self
|
||||
.methods
|
||||
.iter()
|
||||
.map(|method| {
|
||||
serde_json::json!({
|
||||
"label": format!("bds.{}.{}", method.namespace, method.name),
|
||||
"insert_text": self.example_call(method),
|
||||
"detail": self.signature(method),
|
||||
"description": method.description,
|
||||
"parameters": method.params.iter().map(|param| serde_json::json!({
|
||||
"name": param.name,
|
||||
"type": param.type_name,
|
||||
"required": param.required,
|
||||
})).collect::<Vec<_>>(),
|
||||
"returns": method.returns,
|
||||
})
|
||||
})
|
||||
.chain(self.root_methods.iter().map(|method| {
|
||||
serde_json::json!({
|
||||
"label": format!("bds.{}", method.name),
|
||||
"insert_text": self.example_call(method),
|
||||
"detail": self.signature(method),
|
||||
"description": method.description,
|
||||
"parameters": method.params.iter().map(|param| serde_json::json!({
|
||||
"name": param.name,
|
||||
"type": param.type_name,
|
||||
"required": param.required,
|
||||
})).collect::<Vec<_>>(),
|
||||
"returns": method.returns,
|
||||
})
|
||||
}))
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string_pretty(&completions).expect("completion data is serializable") + "\n"
|
||||
}
|
||||
|
||||
fn render_method(&self, output: &mut String, method: &ApiMethod) {
|
||||
let path = method_path(method);
|
||||
output.push_str(&format!("\n### `{path}`\n\n{}\n\n", method.description));
|
||||
if method.compatibility == "ruds" {
|
||||
output.push_str(
|
||||
"> **RuDS extension:** this helper is not part of the portable bDS2 API.\n\n",
|
||||
);
|
||||
}
|
||||
output.push_str(&format!(
|
||||
"**Signature**\n\n```text\n{}\n```\n\n**Parameters**\n\n",
|
||||
self.signature(method)
|
||||
));
|
||||
if method.params.is_empty() {
|
||||
output.push_str("None.\n");
|
||||
} else {
|
||||
output.push_str("| Name | Type | Required | Example |\n| --- | --- | --- | --- |\n");
|
||||
for param in &method.params {
|
||||
output.push_str(&format!(
|
||||
"| `{}` | `{}` | {} | `{}` |\n",
|
||||
param.name,
|
||||
param.type_name.replace('|', "\\|"),
|
||||
if param.required { "Yes" } else { "No" },
|
||||
example_argument(param).replace('|', "\\|")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str(&format!(
|
||||
"\n**Returns**\n\n{}",
|
||||
self.render_return_type(&method.returns)
|
||||
));
|
||||
if method.returns.contains("nil") {
|
||||
output.push_str(" `nil` means no value was available or the host operation failed.");
|
||||
} else if method.returns == "boolean" {
|
||||
output.push_str(" `false` means the operation was rejected or failed.");
|
||||
}
|
||||
output.push_str(&format!(
|
||||
"\n\n**Example call**\n\n```lua\nlocal result = {}\n```\n\n**Example response**\n\n```lua\n{}\n```\n",
|
||||
self.example_call(method),
|
||||
self.example_response(&method.returns)
|
||||
));
|
||||
}
|
||||
|
||||
fn signature(&self, method: &ApiMethod) -> String {
|
||||
let params = method
|
||||
.params
|
||||
.iter()
|
||||
.map(|param| {
|
||||
format!(
|
||||
"{}{}: {}",
|
||||
param.name,
|
||||
if param.required { "" } else { "?" },
|
||||
param.type_name
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("{}({params}) -> {}", method_path(method), method.returns)
|
||||
}
|
||||
|
||||
fn example_call(&self, method: &ApiMethod) -> String {
|
||||
let arguments = method
|
||||
.params
|
||||
.iter()
|
||||
.map(example_argument)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("{}({arguments})", method_path(method))
|
||||
}
|
||||
|
||||
fn render_return_type(&self, returns: &str) -> String {
|
||||
let references = self
|
||||
.types
|
||||
.iter()
|
||||
.filter(|api_type| returns.contains(&api_type.name))
|
||||
.map(|api_type| {
|
||||
format!(
|
||||
"[`{}`](TYPES.md#{})",
|
||||
api_type.name,
|
||||
api_type.name.to_lowercase()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if references.is_empty() {
|
||||
format!("`{returns}`.")
|
||||
} else {
|
||||
format!("`{returns}`. See {}.", references.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn example_response(&self, returns: &str) -> String {
|
||||
self.example_response_named("result", returns)
|
||||
}
|
||||
|
||||
fn example_response_named(&self, name: &str, returns: &str) -> String {
|
||||
let base = returns.split('|').next().unwrap_or(returns).trim();
|
||||
if let Some(element) = base.strip_suffix("[]") {
|
||||
if let Some(api_type) = self.types.iter().find(|item| item.name == element) {
|
||||
return format!("{{\n{}\n}}", indent(&self.example_for_type(api_type), 2));
|
||||
}
|
||||
return match element {
|
||||
"string" => "{ \"example\" }".to_owned(),
|
||||
_ => "{ { key = \"value\" } }".to_owned(),
|
||||
};
|
||||
}
|
||||
if let Some(api_type) = self.types.iter().find(|item| item.name == base) {
|
||||
return self.example_for_type(api_type);
|
||||
}
|
||||
example_value(name, base)
|
||||
}
|
||||
|
||||
fn example_for_type(&self, api_type: &ApiType) -> String {
|
||||
let fields = api_type
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(field, type_name)| {
|
||||
format!(
|
||||
" {field} = {},",
|
||||
self.example_response_named(field, type_name.as_str().unwrap_or("any"))
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("{{\n{fields}\n}}")
|
||||
}
|
||||
}
|
||||
|
||||
fn method_path(method: &ApiMethod) -> String {
|
||||
if method.namespace.is_empty() {
|
||||
format!("bds.{}", method.name)
|
||||
} else {
|
||||
format!("bds.{}.{}", method.namespace, method.name)
|
||||
}
|
||||
}
|
||||
|
||||
fn bds2_compatibility() -> String {
|
||||
"bds2".to_owned()
|
||||
}
|
||||
|
||||
fn example_argument(param: &ApiParameter) -> String {
|
||||
match param.type_name.as_str() {
|
||||
"table" => match param.name.as_str() {
|
||||
"credentials" => "{ host = \"example.com\", username = \"author\" }".to_owned(),
|
||||
"filters" => "{ status = \"draft\" }".to_owned(),
|
||||
"options" => "{ language = \"en\" }".to_owned(),
|
||||
"prefs" => "{ publish_drafts = false }".to_owned(),
|
||||
"source_tag_ids" => "{ \"tag-1\", \"tag-2\" }".to_owned(),
|
||||
"updates" => "{ description = \"Updated description\" }".to_owned(),
|
||||
"payload" => "{ current = 1, total = 10, message = \"Working\" }".to_owned(),
|
||||
_ => "{ title = \"Example\" }".to_owned(),
|
||||
},
|
||||
"number" => match param.name.as_str() {
|
||||
"total" => "100".to_owned(),
|
||||
_ => "1".to_owned(),
|
||||
},
|
||||
"boolean" => "true".to_owned(),
|
||||
type_name if type_name.contains("string") => example_string(¶m.name),
|
||||
_ => "nil".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn example_value(name: &str, type_name: &str) -> String {
|
||||
let base = type_name.split('|').next().unwrap_or(type_name).trim();
|
||||
if base.ends_with("[]") {
|
||||
return if base == "string[]" {
|
||||
"{ \"example\" }".to_owned()
|
||||
} else {
|
||||
"{ { key = \"value\" } }".to_owned()
|
||||
};
|
||||
}
|
||||
match base {
|
||||
"nil" => "nil".to_owned(),
|
||||
"boolean" => "true".to_owned(),
|
||||
"integer" => "1".to_owned(),
|
||||
"number" => "0.5".to_owned(),
|
||||
"table" => "{ key = \"value\" }".to_owned(),
|
||||
value if value.contains("ISO-8601") => "\"2026-07-19T08:00:00Z\"".to_owned(),
|
||||
"string" => example_string(name),
|
||||
_ => "{ key = \"value\" }".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn example_string(name: &str) -> String {
|
||||
let value = match name {
|
||||
"action" => "new-post",
|
||||
"alt" => "A descriptive alternative",
|
||||
"caption" => "Example caption",
|
||||
"color" => "#336699",
|
||||
"content" | "text" => "Example content",
|
||||
"data_path" | "folder_path" | "item_path" | "source_path" | "file_path" => "/path/to/item",
|
||||
"default_author" => "Ada Author",
|
||||
"entrypoint" => "main",
|
||||
"kind" => "utility",
|
||||
"language" | "main_language" => "en",
|
||||
"message" => "Working",
|
||||
"mime_type" => "image/png",
|
||||
"name" | "new_name" => "Example",
|
||||
"original_name" => "image.png",
|
||||
"public_url" => "https://example.com",
|
||||
"query" => "rust",
|
||||
"size" => "small",
|
||||
"slug" | "post_template_slug" => "example-post",
|
||||
"status" => "draft",
|
||||
"title" => "Example post",
|
||||
value if value == "id" || value.ends_with("_id") => "example-id",
|
||||
_ => "example",
|
||||
};
|
||||
format!("\"{value}\"")
|
||||
}
|
||||
|
||||
fn field_description(field: &str) -> &'static str {
|
||||
match field {
|
||||
"id" => "Stable record identifier.",
|
||||
"project_id" => "Identifier of the owning project.",
|
||||
"name" => "Human-readable name.",
|
||||
"title" => "Human-readable title.",
|
||||
"slug" => "URL-safe record identifier.",
|
||||
"description" => "Human-readable description.",
|
||||
"status" => "Current lifecycle state.",
|
||||
"progress" => "Completion value reported by the task.",
|
||||
"message" => "Latest user-facing task message.",
|
||||
"created_at" => "Creation timestamp.",
|
||||
"updated_at" => "Last-update timestamp.",
|
||||
"language" | "main_language" => "BCP 47 language code.",
|
||||
"blog_languages" => "Languages configured for the blog.",
|
||||
"categories" => "Assigned category names.",
|
||||
"tags" => "Assigned tag names.",
|
||||
"active_count" => "Number of active tasks.",
|
||||
"running_count" => "Number of currently running tasks.",
|
||||
"pending_count" => "Number of queued tasks.",
|
||||
"tasks" => "Tasks included in this status snapshot.",
|
||||
"errors" => "Validation error messages.",
|
||||
"valid" => "Whether validation succeeded.",
|
||||
"is_active" => "Whether this is the active project.",
|
||||
"enabled" => "Whether the record is enabled.",
|
||||
"data_path" => "Filesystem path containing project data.",
|
||||
"public_url" => "Published site base URL.",
|
||||
"default_author" => "Default post author name.",
|
||||
"publishing_preferences" => "Project publishing configuration.",
|
||||
"backlinks" => "Links from other posts to this post.",
|
||||
"links_to" => "Links from this post to other posts.",
|
||||
"original_name" => "Original imported filename.",
|
||||
"mime_type" => "Media MIME type.",
|
||||
"file_path" => "Stored media file path.",
|
||||
"alt" => "Alternative text for the media.",
|
||||
"caption" => "Media caption.",
|
||||
"kind" => "Script or template kind.",
|
||||
"entrypoint" => "Lua function invoked by the runtime.",
|
||||
"color" => "Optional display color.",
|
||||
"post_template_slug" => "Template selected for tagged posts.",
|
||||
_ => "Public value returned by the host API.",
|
||||
}
|
||||
}
|
||||
|
||||
fn indent(value: &str, spaces: usize) -> String {
|
||||
let padding = " ".repeat(spaces);
|
||||
value
|
||||
.lines()
|
||||
.map(|line| format!("{padding}{line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
static API_MANIFEST: LazyLock<ApiManifest> = LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("../../../../docs/scripting/api.json"))
|
||||
.expect("docs/scripting/api.json must be valid")
|
||||
});
|
||||
|
||||
pub fn api_manifest() -> &'static ApiManifest {
|
||||
&API_MANIFEST
|
||||
}
|
||||
@@ -7,6 +7,12 @@ use mlua::{
|
||||
};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
mod core_host;
|
||||
mod manifest;
|
||||
|
||||
pub use core_host::{AppHostHandler, CoreHost};
|
||||
pub use manifest::{ApiManifest, api_manifest};
|
||||
|
||||
const MACRO_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_TOASTS_PER_SCRIPT: usize = 5;
|
||||
@@ -34,6 +40,29 @@ pub struct ScriptExecution {
|
||||
pub toasts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Project/application operations explicitly made available to sandboxed Lua.
|
||||
pub trait HostApi: Send + Sync {
|
||||
fn call(
|
||||
&self,
|
||||
namespace: &str,
|
||||
method: &str,
|
||||
arguments: Vec<JsonValue>,
|
||||
) -> Result<JsonValue, String>;
|
||||
}
|
||||
|
||||
pub(crate) struct UnavailableHost;
|
||||
|
||||
impl HostApi for UnavailableHost {
|
||||
fn call(
|
||||
&self,
|
||||
_namespace: &str,
|
||||
_method: &str,
|
||||
_arguments: Vec<JsonValue>,
|
||||
) -> Result<JsonValue, String> {
|
||||
Err("host capability is unavailable in this execution context".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExecutionControl {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
@@ -48,9 +77,17 @@ impl Default for ExecutionControl {
|
||||
}
|
||||
|
||||
impl ExecutionControl {
|
||||
pub fn from_cancelled(cancelled: Arc<AtomicBool>) -> Self {
|
||||
Self { cancelled }
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Lua source with the same runtime used for execution.
|
||||
@@ -71,12 +108,32 @@ pub fn execute(
|
||||
kind: ExecutionKind,
|
||||
control: &ExecutionControl,
|
||||
) -> Result<ScriptExecution, String> {
|
||||
execute_many(
|
||||
execute_many_with_host(
|
||||
source,
|
||||
entrypoint,
|
||||
std::slice::from_ref(args),
|
||||
kind,
|
||||
control,
|
||||
Arc::new(UnavailableHost),
|
||||
)
|
||||
}
|
||||
|
||||
/// Execute with project-scoped host capabilities.
|
||||
pub fn execute_with_host(
|
||||
source: &str,
|
||||
entrypoint: &str,
|
||||
args: &JsonValue,
|
||||
kind: ExecutionKind,
|
||||
control: &ExecutionControl,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> Result<ScriptExecution, String> {
|
||||
execute_many_with_host(
|
||||
source,
|
||||
entrypoint,
|
||||
std::slice::from_ref(args),
|
||||
kind,
|
||||
control,
|
||||
host,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,6 +144,25 @@ pub fn execute_many(
|
||||
args: &[JsonValue],
|
||||
kind: ExecutionKind,
|
||||
control: &ExecutionControl,
|
||||
) -> Result<ScriptExecution, String> {
|
||||
execute_many_with_host(
|
||||
source,
|
||||
entrypoint,
|
||||
args,
|
||||
kind,
|
||||
control,
|
||||
Arc::new(UnavailableHost),
|
||||
)
|
||||
}
|
||||
|
||||
/// Execute with positional arguments and project-scoped host capabilities.
|
||||
pub fn execute_many_with_host(
|
||||
source: &str,
|
||||
entrypoint: &str,
|
||||
args: &[JsonValue],
|
||||
kind: ExecutionKind,
|
||||
control: &ExecutionControl,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> Result<ScriptExecution, String> {
|
||||
if entrypoint.trim().is_empty() {
|
||||
return Err("script entrypoint is empty".into());
|
||||
@@ -96,7 +172,7 @@ pub fn execute_many(
|
||||
let output = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let progress = Arc::new(Mutex::new(Vec::<ScriptProgress>::new()));
|
||||
let toasts = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
install_host_api(&lua, &output, &progress, &toasts)?;
|
||||
install_host_api(&lua, &output, &progress, &toasts, host)?;
|
||||
|
||||
let started = Instant::now();
|
||||
let cancelled = Arc::clone(&control.cancelled);
|
||||
@@ -175,6 +251,7 @@ fn install_host_api(
|
||||
output: &Arc<Mutex<Vec<String>>>,
|
||||
progress: &Arc<Mutex<Vec<ScriptProgress>>>,
|
||||
toasts: &Arc<Mutex<Vec<String>>>,
|
||||
host: Arc<dyn HostApi>,
|
||||
) -> Result<(), String> {
|
||||
let globals = lua.globals();
|
||||
|
||||
@@ -208,13 +285,14 @@ fn install_host_api(
|
||||
.collect::<Vec<_>>()
|
||||
.join("\t");
|
||||
log_output.lock().unwrap().push(line);
|
||||
Ok(())
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let progress_sink = Arc::clone(progress);
|
||||
let progress_host = Arc::clone(&host);
|
||||
app.set(
|
||||
"progress",
|
||||
lua.create_function(
|
||||
@@ -222,9 +300,18 @@ fn install_host_api(
|
||||
progress_sink.lock().unwrap().push(ScriptProgress {
|
||||
current,
|
||||
total,
|
||||
message,
|
||||
message: message.clone(),
|
||||
});
|
||||
Ok(())
|
||||
let _ = progress_host.call(
|
||||
"bds",
|
||||
"report_progress",
|
||||
vec![serde_json::json!({
|
||||
"current": current,
|
||||
"total": total,
|
||||
"message": message,
|
||||
})],
|
||||
);
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
@@ -239,17 +326,97 @@ fn install_host_api(
|
||||
if sink.len() < MAX_TOASTS_PER_SCRIPT {
|
||||
sink.push(message.chars().take(MAX_TOAST_LENGTH).collect());
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
bds.set("app", app).map_err(|error| error.to_string())?;
|
||||
|
||||
for namespace in api_manifest().namespaces() {
|
||||
let table = if namespace == "app" {
|
||||
bds.get(namespace).map_err(|error| error.to_string())?
|
||||
} else {
|
||||
let table = lua.create_table().map_err(|error| error.to_string())?;
|
||||
bds.set(namespace, table.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
table
|
||||
};
|
||||
for method in api_manifest()
|
||||
.methods
|
||||
.iter()
|
||||
.filter(|method| method.namespace == namespace)
|
||||
{
|
||||
if namespace == "app" && matches!(method.name.as_str(), "log" | "progress" | "toast") {
|
||||
continue;
|
||||
}
|
||||
let host = Arc::clone(&host);
|
||||
let namespace = method.namespace.clone();
|
||||
let method_name = method.name.clone();
|
||||
let returns = method.returns.clone();
|
||||
table
|
||||
.set(
|
||||
method.name.as_str(),
|
||||
lua.create_function(move |lua, values: MultiValue| {
|
||||
let arguments = values
|
||||
.into_iter()
|
||||
.map(|value| lua.from_value(value))
|
||||
.collect::<mlua::Result<Vec<JsonValue>>>()?;
|
||||
let value = host
|
||||
.call(&namespace, &method_name, arguments)
|
||||
.unwrap_or_else(|_| failure_value(&returns));
|
||||
lua.to_value(&value)
|
||||
})
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
let report_sink = Arc::clone(progress);
|
||||
let report_host = Arc::clone(&host);
|
||||
bds.set(
|
||||
"report_progress",
|
||||
lua.create_function(move |lua, payload: Value| {
|
||||
let payload: JsonValue = lua.from_value(payload)?;
|
||||
let current = payload
|
||||
.get("current")
|
||||
.or_else(|| payload.get("progress"))
|
||||
.and_then(JsonValue::as_f64)
|
||||
.unwrap_or_default();
|
||||
let total = payload.get("total").and_then(JsonValue::as_f64);
|
||||
let message = payload
|
||||
.get("message")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_owned);
|
||||
report_sink.lock().unwrap().push(ScriptProgress {
|
||||
current,
|
||||
total,
|
||||
message,
|
||||
});
|
||||
let _ = report_host.call("bds", "report_progress", vec![payload]);
|
||||
Ok(true)
|
||||
})
|
||||
.map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
globals.set("bds", bds).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn failure_value(returns: &str) -> JsonValue {
|
||||
if returns == "boolean" {
|
||||
JsonValue::Bool(false)
|
||||
} else if returns.contains("[]") {
|
||||
JsonValue::Array(Vec::new())
|
||||
} else if returns == "table" {
|
||||
JsonValue::Object(Default::default())
|
||||
} else {
|
||||
JsonValue::Null
|
||||
}
|
||||
}
|
||||
|
||||
fn lua_value_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Nil => "nil".into(),
|
||||
@@ -265,6 +432,292 @@ fn lua_value_text(value: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct RecordingHost(Mutex<Vec<String>>);
|
||||
|
||||
impl HostApi for RecordingHost {
|
||||
fn call(
|
||||
&self,
|
||||
namespace: &str,
|
||||
method: &str,
|
||||
_arguments: Vec<JsonValue>,
|
||||
) -> Result<JsonValue, String> {
|
||||
self.0.lock().unwrap().push(format!("{namespace}.{method}"));
|
||||
Ok(json!(format!("{namespace}.{method}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_and_runtime_expose_exact_core_namespaces() {
|
||||
let manifest = api_manifest();
|
||||
assert_eq!(
|
||||
manifest.namespaces(),
|
||||
[
|
||||
"app",
|
||||
"chat",
|
||||
"media",
|
||||
"meta",
|
||||
"posts",
|
||||
"projects",
|
||||
"publish",
|
||||
"scripts",
|
||||
"tags",
|
||||
"tasks",
|
||||
"templates",
|
||||
]
|
||||
);
|
||||
|
||||
let execution = execute(
|
||||
r#"
|
||||
function main()
|
||||
return {
|
||||
sync = bds.sync,
|
||||
embeddings = bds.embeddings,
|
||||
report_progress = type(bds.report_progress),
|
||||
post_search = type(bds.posts.search),
|
||||
app_toast = type(bds.app.toast),
|
||||
}
|
||||
end
|
||||
"#,
|
||||
"main",
|
||||
&JsonValue::Null,
|
||||
ExecutionKind::Utility,
|
||||
&ExecutionControl::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
execution.value,
|
||||
json!({
|
||||
"report_progress": "function",
|
||||
"post_search": "function",
|
||||
"app_toast": "function",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_calls_from_every_namespace_reach_one_host_dispatcher() {
|
||||
let host = Arc::new(RecordingHost(Mutex::new(Vec::new())));
|
||||
let execution = execute_with_host(
|
||||
r#"
|
||||
function main()
|
||||
return {
|
||||
bds.app.get_data_paths(),
|
||||
bds.projects.get_active(),
|
||||
bds.meta.get_project_metadata(),
|
||||
bds.posts.get_all(),
|
||||
bds.media.get_all(),
|
||||
bds.scripts.get_all(),
|
||||
bds.templates.get_all(),
|
||||
bds.tags.get_all(),
|
||||
bds.tasks.status_snapshot(),
|
||||
bds.publish.upload_site({}),
|
||||
bds.chat.detect_post_language("title", "body"),
|
||||
}
|
||||
end
|
||||
"#,
|
||||
"main",
|
||||
&JsonValue::Null,
|
||||
ExecutionKind::Utility,
|
||||
&ExecutionControl::default(),
|
||||
host.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(execution.value.as_array().unwrap().len(), 11);
|
||||
assert_eq!(host.0.lock().unwrap().len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_manifest_runtime_and_generated_docs_stay_in_sync() {
|
||||
let manifest = api_manifest();
|
||||
let spec = include_str!("../../../../specs/script.allium");
|
||||
for namespace in manifest.namespaces() {
|
||||
let marker = format!("bds.{namespace}:");
|
||||
let start = spec
|
||||
.find(&marker)
|
||||
.unwrap_or_else(|| panic!("missing {marker} in spec"));
|
||||
let list = &spec[start + marker.len()..];
|
||||
let list = &list[..list.find('.').expect("method list ends with a period")];
|
||||
let expected = list
|
||||
.replace("--", "")
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let actual = manifest
|
||||
.methods
|
||||
.iter()
|
||||
.filter(|method| method.namespace == namespace && method.compatibility == "bds2")
|
||||
.map(|method| method.name.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"bds.{namespace} differs from the Allium contract"
|
||||
);
|
||||
}
|
||||
|
||||
let checks = manifest
|
||||
.methods
|
||||
.iter()
|
||||
.map(|method| {
|
||||
format!(
|
||||
"assert(type(bds.{}.{}) == 'function')",
|
||||
method.namespace, method.name
|
||||
)
|
||||
})
|
||||
.chain(
|
||||
manifest
|
||||
.root_methods
|
||||
.iter()
|
||||
.map(|method| format!("assert(type(bds.{}) == 'function')", method.name)),
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
validate(&checks).unwrap();
|
||||
execute(
|
||||
&format!("{checks}\nfunction main() return true end"),
|
||||
"main",
|
||||
&JsonValue::Null,
|
||||
ExecutionKind::Utility,
|
||||
&ExecutionControl::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
manifest.render_reference(),
|
||||
include_str!("../../../../docs/scripting/API_REFERENCE.md")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.render_types(),
|
||||
include_str!("../../../../docs/scripting/TYPES.md")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.render_completions(),
|
||||
include_str!("../../../../docs/scripting/completions.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_reference_documents_every_manifest_entry() {
|
||||
let manifest = api_manifest();
|
||||
let undocumented = manifest
|
||||
.methods
|
||||
.iter()
|
||||
.filter(|method| method.description.trim().is_empty())
|
||||
.map(|method| format!("bds.{}.{}", method.namespace, method.name))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
undocumented.is_empty(),
|
||||
"methods without documentation: {}",
|
||||
undocumented.join(", ")
|
||||
);
|
||||
let undocumented_types = manifest
|
||||
.types
|
||||
.iter()
|
||||
.filter(|api_type| api_type.description.trim().is_empty())
|
||||
.map(|api_type| api_type.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
undocumented_types.is_empty(),
|
||||
"types without documentation: {}",
|
||||
undocumented_types.join(", ")
|
||||
);
|
||||
|
||||
let reference = manifest.render_reference();
|
||||
for section in [
|
||||
"## Usage",
|
||||
"**Parameters**",
|
||||
"**Returns**",
|
||||
"**Example call**",
|
||||
"**Example response**",
|
||||
] {
|
||||
assert!(reference.contains(section), "missing {section}");
|
||||
}
|
||||
let types = manifest.render_types();
|
||||
for section in ["## Value conventions", "**Lua shape**", "| Field | Type |"] {
|
||||
assert!(types.contains(section), "missing {section}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_method_signatures_are_identical_to_bds2() {
|
||||
let manifest = api_manifest();
|
||||
let baseline: JsonValue = serde_json::from_str(include_str!(
|
||||
"../../../../docs/scripting/bds2-core-signatures.json"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(baseline["version"], manifest.version);
|
||||
|
||||
let expected = baseline["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|method| {
|
||||
let path = format!(
|
||||
"{}.{}",
|
||||
method["namespace"].as_str().unwrap(),
|
||||
method["name"].as_str().unwrap()
|
||||
);
|
||||
(
|
||||
path,
|
||||
json!({"params": method["params"], "returns": method["returns"]}),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
let actual = manifest
|
||||
.methods
|
||||
.iter()
|
||||
.filter(|method| method.compatibility == "bds2")
|
||||
.map(|method| {
|
||||
let path = format!("{}.{}", method.namespace, method.name);
|
||||
let params = method
|
||||
.params
|
||||
.iter()
|
||||
.map(|param| {
|
||||
json!({
|
||||
"name": param.name,
|
||||
"type": param.type_name,
|
||||
"required": param.required,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(path, json!({"params": params, "returns": method.returns}))
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_examples_execute() {
|
||||
execute_many(
|
||||
include_str!("../../../../docs/scripting/examples/macro.lua"),
|
||||
"render",
|
||||
&[json!({"text":"Hello"}), json!({"mainLanguage":"en"})],
|
||||
ExecutionKind::Macro,
|
||||
&ExecutionControl::default(),
|
||||
)
|
||||
.unwrap();
|
||||
execute_many(
|
||||
include_str!("../../../../docs/scripting/examples/transform.lua"),
|
||||
"main",
|
||||
&[json!({}), json!({"source":"blogmark"})],
|
||||
ExecutionKind::Transform,
|
||||
&ExecutionControl::default(),
|
||||
)
|
||||
.unwrap();
|
||||
execute_with_host(
|
||||
include_str!("../../../../docs/scripting/examples/utility.lua"),
|
||||
"main",
|
||||
&JsonValue::Null,
|
||||
ExecutionKind::Utility,
|
||||
&ExecutionControl::default(),
|
||||
Arc::new(RecordingHost(Mutex::new(Vec::new()))),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executes_entrypoint_and_routes_output_and_progress() {
|
||||
@@ -290,6 +743,23 @@ mod tests {
|
||||
assert_eq!(result.progress[0].message.as_deref(), Some("half"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_progress_reaches_the_live_host_once() {
|
||||
let host = Arc::new(RecordingHost(Mutex::new(Vec::new())));
|
||||
let result = execute_with_host(
|
||||
"function main() bds.app.progress(1, 2, 'half') end",
|
||||
"main",
|
||||
&JsonValue::Null,
|
||||
ExecutionKind::Utility,
|
||||
&ExecutionControl::default(),
|
||||
Arc::clone(&host) as Arc<dyn HostApi>,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.progress.len(), 1);
|
||||
assert_eq!(host.0.lock().unwrap().as_slice(), &["bds.report_progress"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_hides_ambient_host_capabilities() {
|
||||
let result = execute(
|
||||
|
||||
Reference in New Issue
Block a user