Archived
5035 lines
211 KiB
Rust
5035 lines
211 KiB
Rust
//! el — The Engram language CLI.
|
||
//!
|
||
//! # Commands
|
||
//!
|
||
//! ## Project management
|
||
//! el new <name> scaffold new project with el.toml + src/main.el
|
||
//! el add <package>[@ver] add a dependency to el.toml
|
||
//! el remove <package> remove a dependency from el.toml
|
||
//! el update update all deps to latest compatible versions
|
||
//!
|
||
//! ## Build & run
|
||
//! el build [--target prod] build the project (reads el.toml)
|
||
//! el build --cross build for all configured cross targets
|
||
//! el run build debug and run
|
||
//! el test run tests
|
||
//! el check type-check only
|
||
//! el fmt format source files
|
||
//! el clean clean build artifacts
|
||
//!
|
||
//! ## Registry
|
||
//! el publish publish to registry
|
||
//! el search <query> search registry
|
||
//! el plugin add <plugin> add compiler plugin
|
||
//!
|
||
//! ## Low-level
|
||
//! el build-file <file.el> compile a single .el file (no el.toml needed)
|
||
//! el seal <artifact> seal an existing release artifact
|
||
//! el unseal <artifact> unseal a sealed artifact
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use clap::{Parser, Subcommand};
|
||
use el_build::BuildSystem;
|
||
use el_compiler::{Compiler, CompilerOptions, Target};
|
||
use el_test;
|
||
use el_manifest::{BuildTarget, Manifest};
|
||
use el_seal::{seal as seal_fn, unseal as unseal_fn, SealedArtifact, DeploymentBinding, SealAlgorithm, SealConfig};
|
||
|
||
// ── Global state (thread-local for simplicity) ────────────────────────────────
|
||
|
||
thread_local! {
|
||
static GLOBAL_STATE: std::cell::RefCell<std::collections::HashMap<String, String>> =
|
||
std::cell::RefCell::new(std::collections::HashMap::new());
|
||
|
||
/// Callback set by the interpreter before http_serve blocks.
|
||
/// When a request arrives, http_serve calls this to invoke handle_request.
|
||
static HTTP_SERVE_CALL: std::cell::RefCell<Option<Box<dyn Fn()>>> =
|
||
std::cell::RefCell::new(None);
|
||
|
||
/// Shared bytecode instructions for sub-interpreter calls from http_serve.
|
||
static SERVE_INSTRUCTIONS: std::cell::RefCell<Option<std::sync::Arc<Vec<el_compiler::Bytecode>>>> =
|
||
std::cell::RefCell::new(None);
|
||
|
||
/// Shared fn_table for sub-interpreter calls.
|
||
static SERVE_FN_TABLE: std::cell::RefCell<Option<std::sync::Arc<std::collections::HashMap<String, usize>>>> =
|
||
std::cell::RefCell::new(None);
|
||
}
|
||
|
||
// ── Canvas / native window state ─────────────────────────────────────────────
|
||
|
||
#[derive(Default)]
|
||
struct CanvasState {
|
||
// Window configuration
|
||
title: String,
|
||
width: u32,
|
||
height: u32,
|
||
// Drawing surface (tiny-skia pixmap)
|
||
pixmap: Option<tiny_skia::Pixmap>,
|
||
// Font for text rendering (fontdue)
|
||
font: Option<fontdue::Font>,
|
||
// Input state
|
||
mouse_x: i32,
|
||
mouse_y: i32,
|
||
mouse_buttons: u8, // bit 0 = left, bit 1 = right
|
||
// Events accumulated since last frame (JSON strings)
|
||
events: Vec<String>,
|
||
// Clip rectangle stack: (x, y, w, h)
|
||
clips: Vec<(i32, i32, u32, u32)>,
|
||
}
|
||
|
||
thread_local! {
|
||
static CANVAS: std::cell::RefCell<CanvasState> = std::cell::RefCell::new(CanvasState {
|
||
title: String::new(),
|
||
width: 1200,
|
||
height: 800,
|
||
pixmap: None,
|
||
font: None,
|
||
mouse_x: 0,
|
||
mouse_y: 0,
|
||
mouse_buttons: 0,
|
||
events: Vec::new(),
|
||
clips: Vec::new(),
|
||
});
|
||
}
|
||
|
||
// ── CLI definition ────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Parser, Debug)]
|
||
#[command(
|
||
name = "el",
|
||
about = "The Engram programming language compiler and toolchain",
|
||
version
|
||
)]
|
||
struct Cli {
|
||
#[command(subcommand)]
|
||
command: Command,
|
||
}
|
||
|
||
#[derive(Subcommand, Debug)]
|
||
enum Command {
|
||
// ── Project management ────────────────────────────────────────────────────
|
||
|
||
/// Scaffold a new Engram project with el.toml and src/main.el.
|
||
New {
|
||
/// Project name.
|
||
name: String,
|
||
/// Directory to create the project in (default: <name>/).
|
||
#[arg(long, short = 'd')]
|
||
dir: Option<PathBuf>,
|
||
},
|
||
|
||
/// Add a dependency to el.toml.
|
||
Add {
|
||
/// Package name, optionally with version: `engram-http@1.2`.
|
||
package: String,
|
||
/// Path dependency: `--path ../my-lib`.
|
||
#[arg(long)]
|
||
path: Option<PathBuf>,
|
||
},
|
||
|
||
/// Remove a dependency from el.toml.
|
||
Remove {
|
||
/// Package name.
|
||
package: String,
|
||
},
|
||
|
||
/// Update all dependencies to the latest compatible versions.
|
||
Update,
|
||
|
||
// ── Build & run ───────────────────────────────────────────────────────────
|
||
|
||
/// Build the project (reads el.toml).
|
||
Build {
|
||
/// Override the build target: debug | release | prod.
|
||
#[arg(long)]
|
||
target: Option<String>,
|
||
/// Build for all configured cross-compilation targets.
|
||
#[arg(long)]
|
||
cross: bool,
|
||
/// Path to the project manifest (default: el.toml in current directory).
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
/// Build in debug mode and run the output.
|
||
Run {
|
||
/// Path to the project manifest (default: el.toml).
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
/// Run tests in the current project (reads el.toml).
|
||
Test {
|
||
/// Only run tests matching this name (substring match).
|
||
filter: Option<String>,
|
||
/// Also run e2e tests (requires ENGRAM_URL or ENGRAM_DB_PATH).
|
||
#[arg(long)]
|
||
e2e: bool,
|
||
/// Run unit AND e2e tests.
|
||
#[arg(long)]
|
||
all: bool,
|
||
/// Output format: human (default) | json | junit.
|
||
#[arg(long, default_value = "human")]
|
||
output: String,
|
||
/// Path to the project manifest (default: el.toml).
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
/// Run tests from a single .el file (no el.toml required).
|
||
TestFile {
|
||
/// Source file containing test blocks (*.el).
|
||
file: PathBuf,
|
||
/// Only run tests matching this name (substring match).
|
||
filter: Option<String>,
|
||
/// Also run e2e tests (requires ENGRAM_URL or ENGRAM_DB_PATH).
|
||
#[arg(long)]
|
||
e2e: bool,
|
||
/// Run unit AND e2e tests.
|
||
#[arg(long)]
|
||
all: bool,
|
||
/// Output format: human (default) | json | junit.
|
||
#[arg(long, default_value = "human")]
|
||
output: String,
|
||
},
|
||
|
||
/// Run an Engram source file with the step-debugger attached.
|
||
Debug {
|
||
/// Source file (*.el).
|
||
file: PathBuf,
|
||
/// Set a breakpoint at line N.
|
||
#[arg(long, value_name = "LINE")]
|
||
r#break: Option<u32>,
|
||
},
|
||
|
||
/// Type-check source files without producing artifacts.
|
||
Check {
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
/// Format an engram-lang source file (or all files in a project).
|
||
Fmt {
|
||
/// Single source file to format (*.el). When omitted, formats the whole project.
|
||
file: Option<PathBuf>,
|
||
/// Overwrite the file in place with canonical formatting.
|
||
#[arg(long)]
|
||
in_place: bool,
|
||
/// Exit 1 if the file is not in canonical format (useful in CI). Does not modify the file.
|
||
#[arg(long)]
|
||
check: bool,
|
||
/// Project manifest (used when no `file` is given).
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
/// Lint an engram-lang source file.
|
||
Lint {
|
||
/// Source file to lint (*.el).
|
||
file: PathBuf,
|
||
/// Emit diagnostics as JSON instead of human-readable text.
|
||
#[arg(long)]
|
||
json: bool,
|
||
},
|
||
|
||
/// Remove build artifacts and cache.
|
||
Clean {
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
// ── Registry ──────────────────────────────────────────────────────────────
|
||
|
||
/// Publish this package to the Engram registry.
|
||
Publish {
|
||
/// Registry API key.
|
||
#[arg(long, env = "ENGRAM_REGISTRY_KEY")]
|
||
api_key: Option<String>,
|
||
#[arg(long)]
|
||
manifest: Option<PathBuf>,
|
||
},
|
||
|
||
/// Search the Engram registry.
|
||
Search {
|
||
/// Search query.
|
||
query: String,
|
||
},
|
||
|
||
/// Manage compiler plugins.
|
||
Plugin {
|
||
#[command(subcommand)]
|
||
action: PluginAction,
|
||
},
|
||
|
||
// ── Low-level / single-file ───────────────────────────────────────────────
|
||
|
||
/// Compile and immediately run a single .el source file (no el.toml required).
|
||
RunFile {
|
||
/// Source file (*.el).
|
||
file: PathBuf,
|
||
/// Arguments to pass to the program.
|
||
#[arg(trailing_var_arg = true)]
|
||
args: Vec<String>,
|
||
},
|
||
|
||
/// Compile a single .el source file (no el.toml required).
|
||
BuildFile {
|
||
/// Source file (*.el).
|
||
file: PathBuf,
|
||
/// Compilation target: debug | release | prod.
|
||
#[arg(long, default_value = "debug")]
|
||
target: String,
|
||
/// Output path.
|
||
#[arg(long, short = 'o')]
|
||
output: Option<PathBuf>,
|
||
},
|
||
|
||
/// Seal an existing release artifact.
|
||
Seal {
|
||
artifact: PathBuf,
|
||
#[arg(long, short = 'o')]
|
||
output: Option<PathBuf>,
|
||
},
|
||
|
||
/// Unseal a sealed artifact (requires ENGRAM_SEAL_KEY).
|
||
Unseal {
|
||
artifact: PathBuf,
|
||
#[arg(long, short = 'o')]
|
||
output: Option<PathBuf>,
|
||
},
|
||
}
|
||
|
||
#[derive(Subcommand, Debug)]
|
||
enum PluginAction {
|
||
/// Add a compiler plugin to el.toml.
|
||
Add {
|
||
/// Plugin name, optionally with version: `el-fmt@1.0`.
|
||
plugin: String,
|
||
},
|
||
/// Remove a compiler plugin from el.toml.
|
||
Remove {
|
||
plugin: String,
|
||
},
|
||
/// List installed plugins.
|
||
List,
|
||
}
|
||
|
||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
#[tokio::main]
|
||
async fn main() {
|
||
let cli = Cli::parse();
|
||
if let Err(e) = run(cli).await {
|
||
eprintln!("error: {e}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||
match cli.command {
|
||
// ── Project management ────────────────────────────────────────────────
|
||
|
||
Command::New { name, dir } => {
|
||
cmd_new(&name, dir.as_deref())?;
|
||
}
|
||
|
||
Command::Add { package, path } => {
|
||
cmd_add(&package, path.as_deref())?;
|
||
}
|
||
|
||
Command::Remove { package } => {
|
||
cmd_remove(&package)?;
|
||
}
|
||
|
||
Command::Update => {
|
||
println!("update: checking for newer dependency versions...");
|
||
println!("(registry not yet live — no updates available)");
|
||
}
|
||
|
||
// ── Build & run ───────────────────────────────────────────────────────
|
||
|
||
Command::Build { target, cross, manifest } => {
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
|
||
if cross {
|
||
let results = bs.build_all_targets().await?;
|
||
for (ct, out) in &results {
|
||
println!(
|
||
"built {} ({}) -> {} [{} bytes, {}ms]",
|
||
ct,
|
||
out.target,
|
||
out.artifact_path.display(),
|
||
out.size_bytes,
|
||
out.compile_time_ms,
|
||
);
|
||
}
|
||
println!("cross: {} target(s) built", results.len());
|
||
} else {
|
||
let build_target = target.as_deref().map(parse_build_target).transpose()?;
|
||
let out = bs.build(build_target).await?;
|
||
println!(
|
||
"built {} -> {} [{} bytes, {}ms, sealed={}]",
|
||
bs.manifest.package.name,
|
||
out.artifact_path.display(),
|
||
out.size_bytes,
|
||
out.compile_time_ms,
|
||
out.sealed,
|
||
);
|
||
}
|
||
}
|
||
|
||
Command::Run { manifest } => {
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
let out = bs.build(Some(BuildTarget::Debug)).await?;
|
||
|
||
let artifact = std::fs::read(&out.artifact_path)?;
|
||
let instructions = el_compiler::Bytecode::deserialize_all(&artifact)
|
||
.unwrap_or_default();
|
||
// block_in_place lets the interpreter use reqwest::blocking from within tokio::main
|
||
tokio::task::block_in_place(|| run_interpreter(&instructions));
|
||
}
|
||
|
||
Command::Test { filter, e2e, all, output, manifest } => {
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
|
||
// Find all .el source files in the project
|
||
let entry = bs.manifest.build.entry.clone();
|
||
let entry_path = manifest_path.parent().unwrap_or(std::path::Path::new(".")).join(&entry);
|
||
let source = std::fs::read_to_string(&entry_path)
|
||
.map_err(|e| format!("cannot read {}: {e}", entry_path.display()))?;
|
||
|
||
run_tests_from_source(&source, filter.as_deref(), e2e, all, &output)?;
|
||
}
|
||
|
||
Command::TestFile { file, filter, e2e, all, output } => {
|
||
let source = std::fs::read_to_string(&file)
|
||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||
run_tests_from_source(&source, filter.as_deref(), e2e, all, &output)?;
|
||
}
|
||
|
||
Command::Debug { file, r#break } => {
|
||
let source = std::fs::read_to_string(&file)
|
||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||
|
||
let opts = CompilerOptions {
|
||
target: Target::Debug,
|
||
source_path: file.clone(),
|
||
..Default::default()
|
||
};
|
||
let compiled = Compiler::compile(&source, opts)?;
|
||
let instructions = el_compiler::Bytecode::deserialize_all(&compiled.artifact)
|
||
.unwrap_or_default();
|
||
|
||
let mut debugger = el_compiler::Debugger::new();
|
||
if let Some(line) = r#break {
|
||
// Convert line number to a bytecode offset approximation.
|
||
// In a full implementation this would use the source map.
|
||
// For now we use the line number directly as a placeholder offset.
|
||
debugger.add_breakpoint(line as usize);
|
||
println!("debugger: breakpoint set at line {line}");
|
||
} else {
|
||
println!("debugger: breaking on first instruction");
|
||
}
|
||
|
||
println!("debugger: running {} ({} instructions)", file.display(), instructions.len());
|
||
run_interpreter_debug(&instructions, &mut debugger);
|
||
}
|
||
|
||
Command::Check { manifest } => {
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
let diags = bs.check()?;
|
||
if diags.is_empty() {
|
||
println!("check: ok");
|
||
} else {
|
||
for d in &diags {
|
||
eprintln!("warning: {d}");
|
||
}
|
||
}
|
||
}
|
||
|
||
Command::Fmt { file, in_place, check, manifest } => {
|
||
if let Some(path) = file {
|
||
// Single-file mode
|
||
let source = std::fs::read_to_string(&path)
|
||
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||
let formatted = el_fmt::format(&source)
|
||
.map_err(|e| format!("fmt error: {e}"))?;
|
||
if check {
|
||
if formatted != source {
|
||
eprintln!("error: {} is not in canonical format", path.display());
|
||
std::process::exit(1);
|
||
}
|
||
println!("ok: {} is already formatted", path.display());
|
||
} else if in_place {
|
||
std::fs::write(&path, &formatted)
|
||
.map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||
println!("formatted: {}", path.display());
|
||
} else {
|
||
print!("{formatted}");
|
||
}
|
||
} else {
|
||
// Project mode — fall back to build system's fmt
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
bs.fmt()?;
|
||
}
|
||
}
|
||
|
||
Command::Lint { file, json } => {
|
||
let source = std::fs::read_to_string(&file)
|
||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||
let mut report = el_lint::lint(&source)
|
||
.map_err(|e| format!("lint error: {e}"))?;
|
||
report.file_path = Some(file.display().to_string());
|
||
if json {
|
||
println!("{}", report.to_json());
|
||
} else {
|
||
print!("{}", report.display());
|
||
}
|
||
if report.has_errors() {
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
Command::Clean { manifest } => {
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
bs.clean()?;
|
||
}
|
||
|
||
// ── Registry ──────────────────────────────────────────────────────────
|
||
|
||
Command::Publish { api_key, manifest } => {
|
||
let key = api_key.ok_or("publish requires --api-key or ENGRAM_REGISTRY_KEY env var")?;
|
||
let manifest_path = resolve_manifest(manifest.as_deref())?;
|
||
let bs = BuildSystem::from_manifest_file(&manifest_path)?;
|
||
// Build release artifact first
|
||
let out = bs.build(Some(BuildTarget::Release)).await?;
|
||
let client = el_registry::RegistryClient::new();
|
||
client.publish(&bs.manifest, &out.artifact_path, &key).await?;
|
||
println!("published {} v{}", bs.manifest.package.name, bs.manifest.package.version);
|
||
}
|
||
|
||
Command::Search { query } => {
|
||
let client = el_registry::RegistryClient::new();
|
||
println!("searching registry for '{query}'...");
|
||
match client.search(&query).await {
|
||
Ok(results) => {
|
||
if results.is_empty() {
|
||
println!("no results found");
|
||
}
|
||
for pkg in &results {
|
||
println!(" {} v{} — {}", pkg.name, pkg.version, pkg.description);
|
||
}
|
||
}
|
||
Err(e) => {
|
||
eprintln!("registry unavailable (server not yet deployed): {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
Command::Plugin { action } => match action {
|
||
PluginAction::Add { plugin } => {
|
||
cmd_plugin_add(&plugin)?;
|
||
}
|
||
PluginAction::Remove { plugin } => {
|
||
println!("remove plugin: {plugin} (not yet implemented)");
|
||
}
|
||
PluginAction::List => {
|
||
let manifest_path = resolve_manifest(None)?;
|
||
let m = Manifest::from_file(&manifest_path)?;
|
||
if m.plugins.is_empty() {
|
||
println!("no plugins installed");
|
||
}
|
||
for (name, ver) in &m.plugins {
|
||
println!(" {name} = \"{ver}\"");
|
||
}
|
||
}
|
||
},
|
||
|
||
// ── Low-level / single-file ───────────────────────────────────────────
|
||
|
||
Command::RunFile { file, args } => {
|
||
let source = resolve_imports(&file)
|
||
.map_err(|e| format!("cannot resolve imports for {}: {e}", file.display()))?;
|
||
|
||
let opts = CompilerOptions {
|
||
target: Target::Debug,
|
||
source_path: file.clone(),
|
||
..Default::default()
|
||
};
|
||
let compiled = Compiler::compile(&source, opts)?;
|
||
for d in &compiled.diagnostics {
|
||
eprintln!("warning: {d}");
|
||
}
|
||
let instructions = el_compiler::Bytecode::deserialize_all(&compiled.artifact)
|
||
.unwrap_or_default();
|
||
// block_in_place lets the interpreter use reqwest::blocking from within tokio::main
|
||
tokio::task::block_in_place(|| run_interpreter_with_args(&instructions, &args));
|
||
}
|
||
|
||
Command::BuildFile { file, target, output } => {
|
||
let source = std::fs::read_to_string(&file)
|
||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||
|
||
let compilation_target = parse_target(&target)?;
|
||
let out_path = output.unwrap_or_else(|| {
|
||
let stem = file.file_stem().unwrap_or_default().to_string_lossy();
|
||
match &compilation_target {
|
||
Target::Debug | Target::Release => PathBuf::from(format!("{stem}.elc")),
|
||
Target::Prod => PathBuf::from(format!("{stem}.sealed")),
|
||
}
|
||
});
|
||
|
||
let seal_config = build_seal_config()?;
|
||
let opts = CompilerOptions {
|
||
target: compilation_target,
|
||
output_path: out_path.clone(),
|
||
source_path: file.clone(),
|
||
engram_db_path: None,
|
||
seal_config,
|
||
};
|
||
|
||
let output = Compiler::compile(&source, opts)?;
|
||
for d in &output.diagnostics {
|
||
eprintln!("warning: {d}");
|
||
}
|
||
|
||
std::fs::write(&out_path, &output.artifact)
|
||
.map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
|
||
|
||
if let Some(sm) = &output.source_map {
|
||
let sm_path = out_path.with_extension("map.json");
|
||
std::fs::write(&sm_path, sm)
|
||
.map_err(|e| format!("cannot write source map: {e}"))?;
|
||
println!("compiled {} -> {} (source map: {})",
|
||
file.display(), out_path.display(), sm_path.display());
|
||
} else {
|
||
println!("compiled {} -> {} [sealed={}]",
|
||
file.display(), out_path.display(), output.sealed);
|
||
}
|
||
}
|
||
|
||
Command::Seal { artifact, output } => {
|
||
let bytes = std::fs::read(&artifact)
|
||
.map_err(|e| format!("cannot read {}: {e}", artifact.display()))?;
|
||
|
||
let out_path = output.unwrap_or_else(|| {
|
||
let mut p = artifact.clone();
|
||
let ext = format!("{}.sealed",
|
||
p.extension().unwrap_or_default().to_string_lossy());
|
||
p.set_extension(ext);
|
||
p
|
||
});
|
||
|
||
let config = build_seal_config()?;
|
||
let sealed = seal_fn(&bytes, &config)?;
|
||
let artifact_bytes = sealed.to_bytes()?;
|
||
std::fs::write(&out_path, &artifact_bytes)
|
||
.map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
|
||
println!("sealed {} -> {} ({} bytes)",
|
||
artifact.display(), out_path.display(), artifact_bytes.len());
|
||
}
|
||
|
||
Command::Unseal { artifact, output } => {
|
||
let bytes = std::fs::read(&artifact)
|
||
.map_err(|e| format!("cannot read {}: {e}", artifact.display()))?;
|
||
|
||
let out_path = output.unwrap_or_else(|| artifact.with_extension("elc"));
|
||
|
||
let sealed = SealedArtifact::from_bytes(&bytes)?;
|
||
let key_str = std::env::var("ENGRAM_SEAL_KEY").unwrap_or_default();
|
||
let key_bytes = key_str.as_bytes();
|
||
let plaintext = unseal_fn(&sealed, key_bytes)?;
|
||
|
||
std::fs::write(&out_path, &plaintext)
|
||
.map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
|
||
println!("unsealed {} -> {} ({} bytes)",
|
||
artifact.display(), out_path.display(), plaintext.len());
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ── Command implementations ───────────────────────────────────────────────────
|
||
|
||
fn cmd_new(name: &str, dir: Option<&std::path::Path>) -> Result<(), Box<dyn std::error::Error>> {
|
||
let project_dir = dir
|
||
.map(|d| d.to_path_buf())
|
||
.unwrap_or_else(|| PathBuf::from(name));
|
||
|
||
if project_dir.exists() {
|
||
return Err(format!("directory '{}' already exists", project_dir.display()).into());
|
||
}
|
||
|
||
std::fs::create_dir_all(project_dir.join("src"))?;
|
||
|
||
// Write el.toml
|
||
let manifest_content = format!(
|
||
r#"[package]
|
||
name = "{name}"
|
||
version = "0.1.0"
|
||
description = ""
|
||
authors = []
|
||
license = "MIT"
|
||
edition = "2026"
|
||
|
||
[dependencies]
|
||
|
||
[build]
|
||
target = "debug"
|
||
entry = "src/main.el"
|
||
output = "dist/"
|
||
|
||
[cross]
|
||
targets = []
|
||
"#
|
||
);
|
||
std::fs::write(project_dir.join("el.toml"), manifest_content)?;
|
||
|
||
// Write src/main.el
|
||
let main_el = format!(
|
||
r#"// {name} — entry point
|
||
|
||
fn main() -> Void {{
|
||
let msg: String = "Hello from {name}!"
|
||
println(msg)
|
||
}}
|
||
"#
|
||
);
|
||
std::fs::write(project_dir.join("src").join("main.el"), main_el)?;
|
||
|
||
// Write .gitignore
|
||
std::fs::write(project_dir.join(".gitignore"), "dist/\n.el/\n")?;
|
||
|
||
println!("created project '{name}' in {}/", project_dir.display());
|
||
println!(" el.toml");
|
||
println!(" src/main.el");
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_add(package: &str, path: Option<&std::path::Path>) -> Result<(), Box<dyn std::error::Error>> {
|
||
let manifest_path = resolve_manifest(None)?;
|
||
let mut manifest_text = std::fs::read_to_string(&manifest_path)?;
|
||
|
||
let dep_line = if let Some(p) = path {
|
||
format!(
|
||
r#"{package} = {{ path = "{}" }}"#,
|
||
p.display()
|
||
)
|
||
} else {
|
||
// Parse name@version
|
||
let (pkg_name, version) = if let Some((n, v)) = package.split_once('@') {
|
||
(n, v.to_string())
|
||
} else {
|
||
(package, "*".to_string())
|
||
};
|
||
format!(r#"{pkg_name} = "{version}""#)
|
||
};
|
||
|
||
// Append to [dependencies] section
|
||
if let Some(idx) = manifest_text.find("[dependencies]") {
|
||
// Find next section or end
|
||
let after = &manifest_text[idx + "[dependencies]".len()..];
|
||
let insert_pos = after
|
||
.find("\n[")
|
||
.map(|i| idx + "[dependencies]".len() + i)
|
||
.unwrap_or(manifest_text.len());
|
||
manifest_text.insert_str(insert_pos, &format!("\n{dep_line}"));
|
||
} else {
|
||
manifest_text.push_str(&format!("\n[dependencies]\n{dep_line}\n"));
|
||
}
|
||
|
||
std::fs::write(&manifest_path, &manifest_text)?;
|
||
println!("added: {dep_line}");
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_remove(package: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||
let manifest_path = resolve_manifest(None)?;
|
||
let manifest_text = std::fs::read_to_string(&manifest_path)?;
|
||
|
||
// Remove the line containing `package = ...` from [dependencies]
|
||
let updated: String = manifest_text
|
||
.lines()
|
||
.filter(|line| {
|
||
let trimmed = line.trim();
|
||
!trimmed.starts_with(package)
|
||
|| !trimmed[package.len()..].trim_start().starts_with('=')
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
// Preserve trailing newline
|
||
let updated = if manifest_text.ends_with('\n') {
|
||
format!("{updated}\n")
|
||
} else {
|
||
updated
|
||
};
|
||
|
||
std::fs::write(&manifest_path, &updated)?;
|
||
println!("removed: {package}");
|
||
Ok(())
|
||
}
|
||
|
||
fn cmd_plugin_add(plugin: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||
let manifest_path = resolve_manifest(None)?;
|
||
let mut manifest_text = std::fs::read_to_string(&manifest_path)?;
|
||
|
||
let (plugin_name, version) = if let Some((n, v)) = plugin.split_once('@') {
|
||
(n.to_string(), v.to_string())
|
||
} else {
|
||
(plugin.to_string(), "*".to_string())
|
||
};
|
||
|
||
let plugin_line = format!(r#"{plugin_name} = "{version}""#);
|
||
|
||
if let Some(idx) = manifest_text.find("[plugins]") {
|
||
let after = &manifest_text[idx + "[plugins]".len()..];
|
||
let insert_pos = after
|
||
.find("\n[")
|
||
.map(|i| idx + "[plugins]".len() + i)
|
||
.unwrap_or(manifest_text.len());
|
||
manifest_text.insert_str(insert_pos, &format!("\n{plugin_line}"));
|
||
} else {
|
||
manifest_text.push_str(&format!("\n[plugins]\n{plugin_line}\n"));
|
||
}
|
||
|
||
std::fs::write(&manifest_path, &manifest_text)?;
|
||
println!("added plugin: {plugin_line}");
|
||
Ok(())
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/// Resolve `import "path.el"` directives by reading and concatenating source files.
|
||
/// Imports are resolved relative to the directory of the file being imported from.
|
||
/// Circular imports are detected via a visited set.
|
||
fn resolve_imports(file: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
|
||
let mut visited = std::collections::HashSet::new();
|
||
resolve_imports_inner(file, &mut visited)
|
||
}
|
||
|
||
fn resolve_imports_inner(
|
||
file: &std::path::Path,
|
||
visited: &mut std::collections::HashSet<PathBuf>,
|
||
) -> Result<String, Box<dyn std::error::Error>> {
|
||
let canonical = file.canonicalize()
|
||
.unwrap_or_else(|_| file.to_path_buf());
|
||
|
||
if visited.contains(&canonical) {
|
||
return Ok(String::new()); // circular — skip
|
||
}
|
||
visited.insert(canonical.clone());
|
||
|
||
let dir = file.parent().unwrap_or(std::path::Path::new("."));
|
||
let source = std::fs::read_to_string(file)
|
||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||
|
||
let mut out = String::new();
|
||
|
||
for line in source.lines() {
|
||
let trimmed = line.trim();
|
||
if let Some(rest) = trimmed.strip_prefix("import ") {
|
||
// import "filename.el"
|
||
let rest = rest.trim();
|
||
if rest.starts_with('"') && rest.ends_with('"') {
|
||
let import_path_str = &rest[1..rest.len() - 1];
|
||
let import_path = dir.join(import_path_str);
|
||
let imported = resolve_imports_inner(&import_path, visited)?;
|
||
out.push_str(&imported);
|
||
out.push('\n');
|
||
} else {
|
||
out.push_str(line);
|
||
out.push('\n');
|
||
}
|
||
} else {
|
||
out.push_str(line);
|
||
out.push('\n');
|
||
}
|
||
}
|
||
|
||
Ok(out)
|
||
}
|
||
|
||
/// Find the nearest `el.toml` starting from the current directory.
|
||
fn resolve_manifest(path: Option<&std::path::Path>) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||
if let Some(p) = path {
|
||
return Ok(p.to_path_buf());
|
||
}
|
||
let cwd = std::env::current_dir()?;
|
||
Manifest::find_manifest(&cwd).map_err(|e| e.into())
|
||
}
|
||
|
||
fn parse_build_target(s: &str) -> Result<BuildTarget, Box<dyn std::error::Error>> {
|
||
s.parse::<BuildTarget>().map_err(|e| e.into())
|
||
}
|
||
|
||
fn parse_target(s: &str) -> Result<Target, String> {
|
||
match s {
|
||
"debug" => Ok(Target::Debug),
|
||
"release" => Ok(Target::Release),
|
||
"prod" => Ok(Target::Prod),
|
||
other => Err(format!("unknown target '{other}': use debug, release, or prod")),
|
||
}
|
||
}
|
||
|
||
fn build_seal_config() -> Result<SealConfig, String> {
|
||
Ok(SealConfig {
|
||
algorithm: SealAlgorithm::Aes256Gcm,
|
||
deployment_binding: if std::env::var("ENGRAM_SEAL_KEY").is_ok() {
|
||
DeploymentBinding::EnvironmentKey("ENGRAM_SEAL_KEY".into())
|
||
} else {
|
||
DeploymentBinding::None
|
||
},
|
||
})
|
||
}
|
||
|
||
/// Discover tests in source, filter, run, and print results.
|
||
fn run_tests_from_source(
|
||
source: &str,
|
||
filter: Option<&str>,
|
||
e2e: bool,
|
||
all: bool,
|
||
output_fmt: &str,
|
||
) -> Result<(), Box<dyn std::error::Error>> {
|
||
use el_test::{TestReport, TestRunner};
|
||
|
||
let mut tests = el_test::discover(source)?;
|
||
|
||
// Apply name filter
|
||
if let Some(f) = filter {
|
||
tests.retain(|t| t.name.contains(f));
|
||
}
|
||
|
||
if tests.is_empty() {
|
||
println!("no tests found");
|
||
return Ok(());
|
||
}
|
||
|
||
let engram_url = std::env::var("ENGRAM_URL").ok().or_else(|| std::env::var("ENGRAM_DB_PATH").ok());
|
||
let url_ref = engram_url.as_deref();
|
||
|
||
let runner = TestRunner::new();
|
||
let results = if all {
|
||
runner.run_all(&tests, url_ref)
|
||
} else if e2e {
|
||
runner.run_e2e(&tests, url_ref.unwrap_or(""))
|
||
} else {
|
||
runner.run_unit(&tests)
|
||
};
|
||
|
||
let report = TestReport::from_results(results);
|
||
|
||
match output_fmt {
|
||
"json" => println!("{}", report.to_json()),
|
||
"junit" => println!("{}", report.to_junit_xml()),
|
||
_ => report.print(),
|
||
}
|
||
|
||
if !report.is_pass() {
|
||
std::process::exit(1);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Minimal interpreter for demonstration (no program args).
|
||
fn run_interpreter(instructions: &[el_compiler::Bytecode]) {
|
||
run_interpreter_with_args(instructions, &[]);
|
||
}
|
||
|
||
// ── JSON / Value conversion helpers ──────────────────────────────────────────
|
||
|
||
/// Convert a serde_json Value to an el Value.
|
||
fn json_value_to_el_value(v: &serde_json::Value) -> el_compiler::Value {
|
||
use el_compiler::Value;
|
||
match v {
|
||
serde_json::Value::String(s) => Value::Str(s.clone()),
|
||
serde_json::Value::Number(n) => {
|
||
if let Some(i) = n.as_i64() {
|
||
Value::Int(i)
|
||
} else if let Some(f) = n.as_f64() {
|
||
Value::Float(f)
|
||
} else {
|
||
Value::Str(n.to_string())
|
||
}
|
||
}
|
||
serde_json::Value::Bool(b) => Value::Bool(*b),
|
||
serde_json::Value::Null => Value::Nil,
|
||
serde_json::Value::Array(arr) => {
|
||
Value::List(arr.iter().map(json_value_to_el_value).collect())
|
||
}
|
||
serde_json::Value::Object(obj) => {
|
||
let fields = obj.iter()
|
||
.map(|(k, v)| (k.clone(), json_value_to_el_value(v)))
|
||
.collect();
|
||
Value::Struct { type_name: "Object".to_string(), fields }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Convert an el Value to a serde_json Value.
|
||
fn el_value_to_json_value(v: &el_compiler::Value) -> serde_json::Value {
|
||
use el_compiler::Value;
|
||
match v {
|
||
Value::Int(n) => serde_json::Value::Number((*n).into()),
|
||
Value::Float(f) => serde_json::Number::from_f64(*f)
|
||
.map(serde_json::Value::Number)
|
||
.unwrap_or(serde_json::Value::Null),
|
||
Value::Str(s) => serde_json::Value::String(s.clone()),
|
||
Value::Bool(b) => serde_json::Value::Bool(*b),
|
||
Value::Nil => serde_json::Value::Null,
|
||
Value::List(items) => {
|
||
serde_json::Value::Array(items.iter().map(el_value_to_json_value).collect())
|
||
}
|
||
Value::Map(pairs) => {
|
||
let mut map = serde_json::Map::new();
|
||
for (k, v) in pairs {
|
||
map.insert(k.clone(), el_value_to_json_value(v));
|
||
}
|
||
serde_json::Value::Object(map)
|
||
}
|
||
Value::ResultOk(inner) => {
|
||
serde_json::json!({"ok": el_value_to_json_value(inner)})
|
||
}
|
||
Value::ResultErr(inner) => {
|
||
serde_json::json!({"err": el_value_to_json_value(inner)})
|
||
}
|
||
Value::Struct { fields, .. } => {
|
||
let mut map = serde_json::Map::new();
|
||
for (k, v) in fields {
|
||
map.insert(k.clone(), el_value_to_json_value(v));
|
||
}
|
||
serde_json::Value::Object(map)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Call the Engram /search endpoint and return results as a Vec of el Values.
|
||
fn engram_activate_search(type_name: &str, query: &str) -> Vec<el_compiler::Value> {
|
||
use el_compiler::Value;
|
||
let engram_url = std::env::var("ENGRAM_URL")
|
||
.unwrap_or_else(|_| "http://localhost:8742".to_string());
|
||
let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_default();
|
||
|
||
let body = serde_json::json!({
|
||
"query": query,
|
||
"limit": 20
|
||
})
|
||
.to_string();
|
||
|
||
let client = reqwest::blocking::Client::new();
|
||
let mut req = client
|
||
.post(format!("{engram_url}/search"))
|
||
.header("Content-Type", "application/json")
|
||
.body(body);
|
||
|
||
if !api_key.is_empty() {
|
||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||
}
|
||
|
||
let result = req
|
||
.send()
|
||
.and_then(|r| r.json::<serde_json::Value>())
|
||
.ok();
|
||
|
||
let results: Vec<Value> = result
|
||
.and_then(|v| v.as_array().cloned())
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.map(json_value_to_el_value)
|
||
.collect();
|
||
|
||
eprintln!(
|
||
"[activate] {type_name} where \"{query}\" → {} results",
|
||
results.len()
|
||
);
|
||
results
|
||
}
|
||
|
||
/// Run a sub-interpreter starting at the given function entry point.
|
||
/// Returns the value left on the stack (the return value).
|
||
fn run_sub_interpreter(
|
||
instructions: &[el_compiler::Bytecode],
|
||
fn_table: &std::collections::HashMap<String, usize>,
|
||
entry: usize,
|
||
) -> el_compiler::Value {
|
||
run_sub_interpreter_with_stack(instructions, fn_table, entry, vec![])
|
||
}
|
||
|
||
fn run_sub_interpreter_with_stack(
|
||
instructions: &[el_compiler::Bytecode],
|
||
fn_table: &std::collections::HashMap<String, usize>,
|
||
entry: usize,
|
||
initial_stack: Vec<el_compiler::Value>,
|
||
) -> el_compiler::Value {
|
||
use el_compiler::{Bytecode, Value};
|
||
|
||
let mut stack: Vec<Value> = initial_stack;
|
||
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||
let mut call_stack: Vec<(usize, std::collections::HashMap<String, Value>)> = Vec::new();
|
||
let mut ip = entry;
|
||
let program_args: Vec<String> = vec![];
|
||
|
||
while ip < instructions.len() {
|
||
match &instructions[ip] {
|
||
Bytecode::Push(v) => stack.push(v.clone()),
|
||
Bytecode::Pop => { stack.pop(); }
|
||
Bytecode::Dup => {
|
||
if let Some(top) = stack.last().cloned() { stack.push(top); }
|
||
}
|
||
Bytecode::Add => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x + y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
|
||
(Value::Str(x), Value::Str(y)) => Value::Str(x + &y),
|
||
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 + y),
|
||
(Value::Float(x), Value::Int(y)) => Value::Float(x + y as f64),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Sub => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x - y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x - y),
|
||
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 - y),
|
||
(Value::Float(x), Value::Int(y)) => Value::Float(x - y as f64),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Mul => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x * y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x * y),
|
||
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 * y),
|
||
(Value::Float(x), Value::Int(y)) => Value::Float(x * y as f64),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Div => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) if y != 0 => Value::Int(x / y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x / y),
|
||
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 / y),
|
||
(Value::Float(x), Value::Int(y)) => Value::Float(x / y as f64),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Eq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(a == b));
|
||
}
|
||
Bytecode::NotEq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(a != b));
|
||
}
|
||
Bytecode::Lt => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(cmp_values(&a, &b) == std::cmp::Ordering::Less));
|
||
}
|
||
Bytecode::Gt => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(cmp_values(&a, &b) == std::cmp::Ordering::Greater));
|
||
}
|
||
Bytecode::LtEq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(cmp_values(&a, &b) != std::cmp::Ordering::Greater));
|
||
}
|
||
Bytecode::GtEq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(cmp_values(&a, &b) != std::cmp::Ordering::Less));
|
||
}
|
||
Bytecode::And => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(
|
||
matches!(a, Value::Bool(true)) && matches!(b, Value::Bool(true))
|
||
));
|
||
}
|
||
Bytecode::Or => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(
|
||
matches!(a, Value::Bool(true)) || matches!(b, Value::Bool(true))
|
||
));
|
||
}
|
||
Bytecode::Not => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Bool(!matches!(v, Value::Bool(true))));
|
||
}
|
||
Bytecode::StoreLocal(name) => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
locals.insert(name.clone(), v);
|
||
}
|
||
Bytecode::LoadLocal(name) => {
|
||
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
}
|
||
Bytecode::Call { name, arity } => {
|
||
let result = dispatch_builtin(name, *arity, &mut stack, &program_args);
|
||
match result {
|
||
BuiltinResult::Handled | BuiltinResult::HttpServe => {}
|
||
BuiltinResult::Exit(code) => std::process::exit(code),
|
||
BuiltinResult::NotBuiltin => {
|
||
if let Some(&entry) = fn_table.get(name.as_str()) {
|
||
let saved = locals.clone();
|
||
call_stack.push((ip + 1, saved));
|
||
ip = entry;
|
||
continue;
|
||
}
|
||
stack.push(Value::Nil);
|
||
}
|
||
}
|
||
}
|
||
Bytecode::GetField(field) => {
|
||
let obj = stack.pop().unwrap_or(Value::Nil);
|
||
let v = match &obj {
|
||
Value::Map(pairs) => pairs.iter()
|
||
.find(|(k, _)| k == field)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil),
|
||
Value::Struct { fields, .. } => fields.iter()
|
||
.find(|(n, _)| n == field)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil),
|
||
_ => Value::Nil,
|
||
};
|
||
stack.push(v);
|
||
}
|
||
Bytecode::GetIndex => {
|
||
let idx = stack.pop().unwrap_or(Value::Nil);
|
||
let obj = stack.pop().unwrap_or(Value::Nil);
|
||
match (obj, idx) {
|
||
(Value::List(items), Value::Int(i)) => {
|
||
let v = if i >= 0 && (i as usize) < items.len() {
|
||
items[i as usize].clone()
|
||
} else {
|
||
Value::Nil
|
||
};
|
||
stack.push(v);
|
||
}
|
||
(Value::Map(pairs), Value::Str(key)) => {
|
||
let v = pairs.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
}
|
||
_ => stack.push(Value::Nil),
|
||
}
|
||
}
|
||
Bytecode::BuildMap(n) => {
|
||
let mut pairs = Vec::new();
|
||
for _ in 0..*n {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
pairs.push((key, val));
|
||
}
|
||
pairs.reverse();
|
||
stack.push(Value::Map(pairs));
|
||
}
|
||
Bytecode::BuildStruct { type_name, fields } => {
|
||
let n = fields.len();
|
||
let mut field_values: Vec<Value> = (0..n).map(|_| stack.pop().unwrap_or(Value::Nil)).collect();
|
||
field_values.reverse();
|
||
let struct_fields: Vec<(String, Value)> = fields.iter().cloned()
|
||
.zip(field_values.into_iter())
|
||
.collect();
|
||
stack.push(Value::Struct {
|
||
type_name: type_name.clone(),
|
||
fields: struct_fields,
|
||
});
|
||
}
|
||
Bytecode::SetField(field) => {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
if let Some(Value::Map(pairs)) = stack.last_mut() {
|
||
if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) {
|
||
entry.1 = val;
|
||
} else {
|
||
pairs.push((field.clone(), val));
|
||
}
|
||
} else if let Some(Value::Struct { fields, .. }) = stack.last_mut() {
|
||
if let Some(entry) = fields.iter_mut().find(|(k, _)| k == field) {
|
||
entry.1 = val;
|
||
} else {
|
||
fields.push((field.clone(), val));
|
||
}
|
||
}
|
||
}
|
||
Bytecode::Jump(offset) => {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
Bytecode::JumpIf(offset) => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if matches!(cond, Value::Bool(true)) {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::JumpIfNot(offset) => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if !matches!(cond, Value::Bool(true)) {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::Return => {
|
||
if call_stack.is_empty() {
|
||
// Return from handle_request — value is on stack
|
||
break;
|
||
}
|
||
if let Some((ret_ip, saved_locals)) = call_stack.pop() {
|
||
locals = saved_locals;
|
||
ip = ret_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::Halt => break,
|
||
Bytecode::Activate { type_name, query } => {
|
||
let results = engram_activate_search(type_name, query);
|
||
stack.push(Value::List(results));
|
||
}
|
||
_ => {}
|
||
}
|
||
ip += 1;
|
||
}
|
||
|
||
stack.pop().unwrap_or(Value::Nil)
|
||
}
|
||
|
||
/// Interpreter with program args — the args() builtin returns these.
|
||
fn run_interpreter_with_args(instructions: &[el_compiler::Bytecode], program_args: &[String]) {
|
||
use el_compiler::{Bytecode, Value};
|
||
let mut stack: Vec<Value> = Vec::new();
|
||
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||
let mut ip = 0usize;
|
||
|
||
// Build a call table: fn name → bytecode offset.
|
||
// We populate this by scanning for __fn_<name> stores first.
|
||
let mut fn_table: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||
// We need a two-pass approach: scan for function entry points then execute.
|
||
// The codegen emits: Jump(skip) [body...] Push(Int(entry)) StoreLocal(__fn_name)
|
||
// We pre-scan to build the table.
|
||
{
|
||
let mut scan_ip = 0usize;
|
||
let mut scan_locals: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
|
||
while scan_ip < instructions.len() {
|
||
match &instructions[scan_ip] {
|
||
Bytecode::Push(Value::Int(n)) => {
|
||
// could be a function entry point — remember it
|
||
if scan_ip + 1 < instructions.len() {
|
||
if let Bytecode::StoreLocal(name) = &instructions[scan_ip + 1] {
|
||
if let Some(fn_name) = name.strip_prefix("__fn_") {
|
||
fn_table.insert(fn_name.to_string(), *n as usize);
|
||
}
|
||
scan_locals.insert(name.clone(), *n);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
scan_ip += 1;
|
||
}
|
||
}
|
||
|
||
// Store instructions and fn_table in thread-locals so http_serve can call
|
||
// handle_request via a sub-interpreter invocation.
|
||
let arc_instructions = std::sync::Arc::new(instructions.to_vec());
|
||
let arc_fn_table = std::sync::Arc::new(fn_table.clone());
|
||
let arc_instructions_clone = arc_instructions.clone();
|
||
let arc_fn_table_clone = arc_fn_table.clone();
|
||
|
||
SERVE_INSTRUCTIONS.with(|si| *si.borrow_mut() = Some(arc_instructions.clone()));
|
||
SERVE_FN_TABLE.with(|sf| *sf.borrow_mut() = Some(arc_fn_table.clone()));
|
||
|
||
// Set up the http_serve callback that calls handle_request(method, path, body)
|
||
HTTP_SERVE_CALL.with(|f| {
|
||
*f.borrow_mut() = Some(Box::new(move || {
|
||
// Load method, path, body from global state (set by http_serve before calling)
|
||
let (method, path, body) = GLOBAL_STATE.with(|gs| {
|
||
let s = gs.borrow();
|
||
(
|
||
s.get("__method__").cloned().unwrap_or_default(),
|
||
s.get("__path__").cloned().unwrap_or_default(),
|
||
s.get("__request__").cloned().unwrap_or_default(),
|
||
)
|
||
});
|
||
|
||
// Run a sub-interpreter starting at handle_request with args on stack
|
||
if let Some(entry) = arc_fn_table_clone.get("handle_request") {
|
||
// Push args in order: method, path, body (they'll be stored via StoreLocal params)
|
||
let initial_stack = vec![
|
||
el_compiler::Value::Str(method),
|
||
el_compiler::Value::Str(path),
|
||
el_compiler::Value::Str(body),
|
||
];
|
||
let result = run_sub_interpreter_with_stack(
|
||
&arc_instructions_clone,
|
||
&arc_fn_table_clone,
|
||
*entry,
|
||
initial_stack,
|
||
);
|
||
// Store the result as __response__ in global state
|
||
let response = match result {
|
||
el_compiler::Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
GLOBAL_STATE.with(|gs| {
|
||
gs.borrow_mut().insert("__response__".to_string(), response);
|
||
});
|
||
} else {
|
||
GLOBAL_STATE.with(|gs| {
|
||
gs.borrow_mut().insert(
|
||
"__response__".to_string(),
|
||
r#"{"error":"handle_request function not found"}"#.to_string(),
|
||
);
|
||
});
|
||
}
|
||
}));
|
||
});
|
||
|
||
// Call stack for user-defined function calls: (return_ip, saved_locals)
|
||
let mut call_stack: Vec<(usize, std::collections::HashMap<String, Value>)> = Vec::new();
|
||
|
||
while ip < instructions.len() {
|
||
match &instructions[ip] {
|
||
Bytecode::Push(v) => stack.push(v.clone()),
|
||
Bytecode::Pop => { stack.pop(); }
|
||
Bytecode::Dup => {
|
||
if let Some(top) = stack.last().cloned() { stack.push(top); }
|
||
}
|
||
Bytecode::Add => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x + y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
|
||
(Value::Str(x), Value::Str(y)) => Value::Str(x + &y),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Sub => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x - y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x - y),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Mul => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x * y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x * y),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Div => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) if y != 0 => Value::Int(x / y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x / y),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::Eq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(a == b));
|
||
}
|
||
Bytecode::NotEq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(a != b));
|
||
}
|
||
Bytecode::Lt => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Bool(x < y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Bool(x < y),
|
||
_ => Value::Bool(false),
|
||
});
|
||
}
|
||
Bytecode::Gt => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Bool(x > y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Bool(x > y),
|
||
_ => Value::Bool(false),
|
||
});
|
||
}
|
||
Bytecode::LtEq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Bool(x <= y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Bool(x <= y),
|
||
_ => Value::Bool(false),
|
||
});
|
||
}
|
||
Bytecode::GtEq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Bool(x >= y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Bool(x >= y),
|
||
_ => Value::Bool(false),
|
||
});
|
||
}
|
||
Bytecode::And => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(
|
||
matches!(a, Value::Bool(true)) && matches!(b, Value::Bool(true))
|
||
));
|
||
}
|
||
Bytecode::Or => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(
|
||
matches!(a, Value::Bool(true)) || matches!(b, Value::Bool(true))
|
||
));
|
||
}
|
||
Bytecode::Not => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Bool(!matches!(v, Value::Bool(true))));
|
||
}
|
||
Bytecode::StoreLocal(name) => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
locals.insert(name.clone(), v);
|
||
}
|
||
Bytecode::LoadLocal(name) => {
|
||
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
}
|
||
Bytecode::Call { name, arity } => {
|
||
let result = dispatch_builtin(name, *arity, &mut stack, program_args);
|
||
match result {
|
||
BuiltinResult::Handled | BuiltinResult::HttpServe => {}
|
||
BuiltinResult::Exit(code) => std::process::exit(code),
|
||
BuiltinResult::NotBuiltin => {
|
||
// Try user-defined function
|
||
if let Some(&entry) = fn_table.get(name.as_str()) {
|
||
// Save current locals and return address
|
||
let saved = locals.clone();
|
||
call_stack.push((ip + 1, saved));
|
||
ip = entry;
|
||
continue;
|
||
}
|
||
// Unknown — push Nil
|
||
stack.push(Value::Nil);
|
||
}
|
||
}
|
||
}
|
||
Bytecode::GetField(field) => {
|
||
let obj = stack.pop().unwrap_or(Value::Nil);
|
||
let v = match obj {
|
||
Value::Map(pairs) => pairs.iter()
|
||
.find(|(k, _)| k == field)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil),
|
||
_ => Value::Nil,
|
||
};
|
||
stack.push(v);
|
||
}
|
||
Bytecode::GetIndex => {
|
||
let idx = stack.pop().unwrap_or(Value::Nil);
|
||
let obj = stack.pop().unwrap_or(Value::Nil);
|
||
match (obj, idx) {
|
||
(Value::List(items), Value::Int(i)) => {
|
||
let v = if i >= 0 && (i as usize) < items.len() {
|
||
items[i as usize].clone()
|
||
} else {
|
||
Value::Nil
|
||
};
|
||
stack.push(v);
|
||
}
|
||
(Value::Map(pairs), Value::Str(key)) => {
|
||
let v = pairs.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
}
|
||
(Value::Str(s), Value::Int(i)) => {
|
||
let c = s.chars().nth(i as usize)
|
||
.map(|c| Value::Str(c.to_string()))
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(c);
|
||
}
|
||
_ => stack.push(Value::Nil),
|
||
}
|
||
}
|
||
Bytecode::BuildMap(n) => {
|
||
let mut pairs = Vec::new();
|
||
for _ in 0..*n {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
pairs.push((key, val));
|
||
}
|
||
pairs.reverse();
|
||
stack.push(Value::Map(pairs));
|
||
}
|
||
Bytecode::BuildStruct { fields, .. } => {
|
||
let mut pairs = Vec::new();
|
||
for field in fields.iter().rev() {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
pairs.push((field.clone(), val));
|
||
}
|
||
pairs.reverse();
|
||
stack.push(Value::Map(pairs));
|
||
}
|
||
Bytecode::SetField(field) => {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
if let Some(Value::Map(pairs)) = stack.last_mut() {
|
||
if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) {
|
||
entry.1 = val;
|
||
} else {
|
||
pairs.push((field.clone(), val));
|
||
}
|
||
}
|
||
}
|
||
Bytecode::Activate { type_name, query } => {
|
||
let results = engram_activate_search(type_name, query);
|
||
stack.push(Value::List(results));
|
||
}
|
||
Bytecode::Jump(offset) => {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
Bytecode::JumpIf(offset) => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if matches!(cond, Value::Bool(true)) {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::JumpIfNot(offset) => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if !matches!(cond, Value::Bool(true)) {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::Return => {
|
||
// Return from a user function call
|
||
if let Some((ret_ip, saved_locals)) = call_stack.pop() {
|
||
locals = saved_locals;
|
||
ip = ret_ip;
|
||
continue;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
Bytecode::Halt => break,
|
||
Bytecode::SealedBegin => {}
|
||
Bytecode::SealedEnd => {}
|
||
Bytecode::Nop => {}
|
||
Bytecode::Reason { query } => {
|
||
let text = soma_reason(query);
|
||
stack.push(Value::Str(text));
|
||
}
|
||
Bytecode::Parallel { entries } => {
|
||
// Spawn one thread per entry, collect into Map
|
||
let instructions_arc = std::sync::Arc::new(instructions.to_vec());
|
||
let fn_table_arc = std::sync::Arc::new(fn_table.clone());
|
||
let locals_arc = std::sync::Arc::new(locals.clone());
|
||
let mut handles: Vec<(String, std::thread::JoinHandle<Value>)> = Vec::new();
|
||
for (name, entry_ip) in entries {
|
||
let instr_clone = instructions_arc.clone();
|
||
let ft_clone = fn_table_arc.clone();
|
||
let ep = *entry_ip;
|
||
let h = std::thread::spawn(move || {
|
||
run_sub_interpreter(&instr_clone, &ft_clone, ep)
|
||
});
|
||
handles.push((name.clone(), h));
|
||
}
|
||
let mut pairs = Vec::new();
|
||
for (name, h) in handles {
|
||
let v = h.join().unwrap_or(Value::Nil);
|
||
pairs.push((name, v));
|
||
}
|
||
stack.push(Value::Map(pairs));
|
||
}
|
||
Bytecode::TraceBegin { label } => {
|
||
let start_ms = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_millis())
|
||
.unwrap_or(0);
|
||
locals.insert(format!("__trace_start_{label}__"), Value::Int(start_ms as i64));
|
||
}
|
||
Bytecode::TraceEnd { label } => {
|
||
let now_ms = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_millis())
|
||
.unwrap_or(0);
|
||
let start_ms = match locals.get(&format!("__trace_start_{label}__")) {
|
||
Some(Value::Int(n)) => *n as u128,
|
||
_ => now_ms,
|
||
};
|
||
let elapsed = now_ms.saturating_sub(start_ms);
|
||
eprintln!("[trace] {label}: {elapsed}ms");
|
||
}
|
||
Bytecode::ContractCheck { message } => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if !matches!(cond, Value::Bool(true)) {
|
||
eprintln!("contract violation: {message}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
Bytecode::DeployFn { fn_name, route, target } => {
|
||
let soma_url = std::env::var("SOMA_URL")
|
||
.unwrap_or_else(|_| "https://neuron.neurontechnologies.ai".into());
|
||
let op_key = std::env::var("SOMA_OPERATOR_KEY").unwrap_or_default();
|
||
let body = serde_json::json!({
|
||
"fn_name": fn_name,
|
||
"route": route,
|
||
"target": target,
|
||
});
|
||
let resp = reqwest::blocking::Client::new()
|
||
.post(format!("{soma_url}/v1/deploy"))
|
||
.header("Authorization", format!("Bearer {op_key}"))
|
||
.json(&body)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"));
|
||
eprintln!("[deploy] {fn_name} -> {route} via {target}: {resp}");
|
||
stack.push(Value::Str(resp));
|
||
}
|
||
_ => {}
|
||
}
|
||
ip += 1;
|
||
}
|
||
}
|
||
|
||
/// Call soma AI inference endpoint and return response text.
|
||
fn soma_reason(query: &str) -> String {
|
||
let soma_url = std::env::var("SOMA_URL")
|
||
.unwrap_or_else(|_| "https://neuron.neurontechnologies.ai".into());
|
||
let op_key = std::env::var("SOMA_OPERATOR_KEY").unwrap_or_default();
|
||
let body = serde_json::json!({
|
||
"model": "neuron",
|
||
"messages": [{"role": "user", "content": query}],
|
||
"max_tokens": 500
|
||
});
|
||
let resp = reqwest::blocking::Client::new()
|
||
.post(format!("{soma_url}/v1/chat/completions"))
|
||
.header("Authorization", format!("Bearer {op_key}"))
|
||
.json(&body)
|
||
.send()
|
||
.and_then(|r| r.json::<serde_json::Value>())
|
||
.ok();
|
||
resp.and_then(|v| v["choices"][0]["message"]["content"].as_str().map(|s| s.to_string()))
|
||
.unwrap_or_else(|| "[soma unavailable]".into())
|
||
}
|
||
|
||
// ── Canvas helper functions ───────────────────────────────────────────────────
|
||
|
||
/// Parse "#rrggbb" or "#rrggbbaa" into tiny-skia Color.
|
||
fn parse_color(s: &str) -> tiny_skia::Color {
|
||
let s = s.trim_start_matches('#');
|
||
let r = u8::from_str_radix(s.get(0..2).unwrap_or("ff"), 16).unwrap_or(255);
|
||
let g = u8::from_str_radix(s.get(2..4).unwrap_or("ff"), 16).unwrap_or(255);
|
||
let b = u8::from_str_radix(s.get(4..6).unwrap_or("ff"), 16).unwrap_or(255);
|
||
let a = u8::from_str_radix(s.get(6..8).unwrap_or("ff"), 16).unwrap_or(255);
|
||
tiny_skia::Color::from_rgba8(r, g, b, a)
|
||
}
|
||
|
||
/// Rasterize `text` at `size` pixels using the stored fontdue font.
|
||
/// Returns (glyphs: Vec<(x_offset, metrics, bitmap)>, total_width).
|
||
fn rasterize_text(text: &str, size: f32) -> (Vec<(i32, fontdue::Metrics, Vec<u8>)>, i32) {
|
||
CANVAS.with(|cv| {
|
||
let cv = cv.borrow();
|
||
let font = match &cv.font { Some(f) => f, None => return (vec![], 0) };
|
||
let mut glyphs = Vec::new();
|
||
let mut cursor_x = 0i32;
|
||
for ch in text.chars() {
|
||
let (metrics, bitmap) = font.rasterize(ch, size);
|
||
glyphs.push((cursor_x, metrics, bitmap));
|
||
cursor_x += metrics.advance_width as i32;
|
||
}
|
||
(glyphs, cursor_x)
|
||
})
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
enum BuiltinResult {
|
||
Handled,
|
||
Exit(i32),
|
||
NotBuiltin,
|
||
/// http_serve was called — the interpreter should treat this like Handled
|
||
/// but the actual serve loop is blocking inside dispatch_builtin.
|
||
HttpServe,
|
||
}
|
||
|
||
fn dispatch_builtin(
|
||
name: &str,
|
||
_arity: u32,
|
||
stack: &mut Vec<el_compiler::Value>,
|
||
program_args: &[String],
|
||
) -> BuiltinResult {
|
||
use el_compiler::Value;
|
||
match name {
|
||
"print" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
print!("{v}");
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"println" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
println!("{v}");
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"log" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
println!("{v}");
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"print_err" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
eprintln!("{v}");
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"args" => {
|
||
let list = program_args.iter()
|
||
.map(|s| Value::Str(s.clone()))
|
||
.collect();
|
||
stack.push(Value::List(list));
|
||
BuiltinResult::Handled
|
||
}
|
||
"cwd" => {
|
||
let path = std::env::current_dir()
|
||
.map(|p| p.to_string_lossy().to_string())
|
||
.unwrap_or_else(|_| ".".to_string());
|
||
stack.push(Value::Str(path));
|
||
BuiltinResult::Handled
|
||
}
|
||
"env" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
// Return empty string when env var is not set (so `env("X") == ""` works)
|
||
let val = std::env::var(&key)
|
||
.map(Value::Str)
|
||
.unwrap_or_else(|_| Value::Str(String::new()));
|
||
stack.push(val);
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_post" => {
|
||
let body = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.post(&url)
|
||
.header("Content-Type", "application/json")
|
||
.header("X-NC-CLI", "true")
|
||
.body(body)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"));
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_get" => {
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::get(&url)
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"));
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"exit" => {
|
||
let code = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as i32,
|
||
_ => 0,
|
||
};
|
||
BuiltinResult::Exit(code)
|
||
}
|
||
"str_contains" => {
|
||
let needle = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let haystack = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(haystack.contains(needle.as_str())));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_starts_with" => {
|
||
let prefix = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(s.starts_with(prefix.as_str())));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_split" => {
|
||
let delim = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let parts: Vec<Value> = s.split(delim.as_str())
|
||
.map(|p| Value::Str(p.to_string()))
|
||
.collect();
|
||
stack.push(Value::List(parts));
|
||
BuiltinResult::Handled
|
||
}
|
||
"list_len" => {
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
stack.push(Value::Int(list.len() as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
"list_get" => {
|
||
let idx = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => -1,
|
||
};
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let v = if idx >= 0 && (idx as usize) < list.len() {
|
||
list[idx as usize].clone()
|
||
} else {
|
||
Value::Nil
|
||
};
|
||
stack.push(v);
|
||
BuiltinResult::Handled
|
||
}
|
||
"int_to_str" => {
|
||
let n = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 0,
|
||
};
|
||
stack.push(Value::Str(n.to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_eq" => {
|
||
let b = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let a = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(a == b));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_get" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let json_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let val = serde_json::from_str::<serde_json::Value>(&json_str)
|
||
.ok()
|
||
.and_then(|v| v.get(&key).cloned())
|
||
.map(|v| match v {
|
||
serde_json::Value::String(s) => Value::Str(s),
|
||
serde_json::Value::Number(n) => {
|
||
if let Some(i) = n.as_i64() {
|
||
Value::Int(i)
|
||
} else {
|
||
Value::Str(n.to_string())
|
||
}
|
||
}
|
||
serde_json::Value::Bool(b) => Value::Bool(b),
|
||
serde_json::Value::Null => Value::Nil,
|
||
other => Value::Str(other.to_string()),
|
||
})
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(val);
|
||
BuiltinResult::Handled
|
||
}
|
||
"__build_list__" => {
|
||
// Already handled inline by codegen for array literals — no-op here
|
||
// The arity items are on the stack; we collect them into a list.
|
||
// But arity is already popped. We push Nil as fallback.
|
||
// In practice, array literals push items then call __build_list__(arity).
|
||
// We need to pop `arity` items and build a list.
|
||
// arity was passed but we only have `_arity` here — use it.
|
||
let mut items = Vec::new();
|
||
for _ in 0.._arity {
|
||
items.push(stack.pop().unwrap_or(Value::Nil));
|
||
}
|
||
items.reverse();
|
||
stack.push(Value::List(items));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Filesystem builtins ───────────────────────────────────────────────
|
||
|
||
"fs_read" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => { stack.push(Value::Nil); return BuiltinResult::Handled; }
|
||
};
|
||
let result = std::fs::read_to_string(&path)
|
||
.map(Value::Str)
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(result);
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_write" => {
|
||
let content = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let ok = std::fs::write(&path, &content).is_ok();
|
||
stack.push(Value::Bool(ok));
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_append" => {
|
||
let content = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
use std::io::Write;
|
||
let ok = std::fs::OpenOptions::new()
|
||
.create(true).append(true).open(&path)
|
||
.and_then(|mut f| f.write_all(content.as_bytes()))
|
||
.is_ok();
|
||
stack.push(Value::Bool(ok));
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_exists" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(std::path::Path::new(&path).exists()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_mkdir" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let ok = std::fs::create_dir_all(&path).is_ok();
|
||
stack.push(Value::Bool(ok));
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_list" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let entries = std::fs::read_dir(&path)
|
||
.map(|rd| {
|
||
rd.filter_map(|e| {
|
||
e.ok().and_then(|e| {
|
||
e.file_name().into_string().ok().map(Value::Str)
|
||
})
|
||
}).collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
stack.push(Value::List(entries));
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_remove" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let p = std::path::Path::new(&path);
|
||
let ok = if p.is_dir() {
|
||
std::fs::remove_dir(p).is_ok()
|
||
} else {
|
||
std::fs::remove_file(p).is_ok()
|
||
};
|
||
stack.push(Value::Bool(ok));
|
||
BuiltinResult::Handled
|
||
}
|
||
"fs_is_dir" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(std::path::Path::new(&path).is_dir()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"path_join" => {
|
||
let name = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let base = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let joined = std::path::Path::new(&base).join(&name);
|
||
stack.push(Value::Str(joined.to_string_lossy().to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"path_parent" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let parent = std::path::Path::new(&path)
|
||
.parent()
|
||
.map(|p| Value::Str(p.to_string_lossy().to_string()))
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(parent);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Filesystem recursive list ─────────────────────────────────────────
|
||
|
||
"fs_list_recursive" => {
|
||
let path = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let skip_dirs = ["node_modules", ".git", ".nc", "target", "__pycache__", ".el"];
|
||
let mut files = Vec::new();
|
||
if let Ok(walker) = walkdir::WalkDir::new(&path).into_iter().collect::<Result<Vec<_>, _>>() {
|
||
for entry in walker {
|
||
// Skip hidden/build dirs
|
||
let should_skip = entry.path().components().any(|c| {
|
||
let s = c.as_os_str().to_string_lossy();
|
||
skip_dirs.iter().any(|d| s == *d)
|
||
});
|
||
if should_skip && entry.path() != std::path::Path::new(&path) {
|
||
continue;
|
||
}
|
||
if entry.file_type().is_file() {
|
||
files.push(Value::Str(entry.path().to_string_lossy().to_string()));
|
||
}
|
||
}
|
||
}
|
||
stack.push(Value::List(files));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Crypto / ID builtins ──────────────────────────────────────────────
|
||
|
||
"blake3_hash" => {
|
||
let content = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let hash = blake3::hash(content.as_bytes());
|
||
stack.push(Value::Str(hash.to_hex().to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"uuid_new" => {
|
||
let id = uuid::Uuid::new_v4().to_string();
|
||
stack.push(Value::Str(id));
|
||
BuiltinResult::Handled
|
||
}
|
||
"now_millis" => {
|
||
let ms = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_millis() as i64)
|
||
.unwrap_or(0);
|
||
stack.push(Value::Int(ms));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── State builtins ────────────────────────────────────────────────────
|
||
|
||
"state_set" => {
|
||
let value = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
GLOBAL_STATE.with(|gs| gs.borrow_mut().insert(key, value));
|
||
stack.push(Value::Bool(true));
|
||
BuiltinResult::Handled
|
||
}
|
||
"state_get" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let val = GLOBAL_STATE.with(|gs| {
|
||
gs.borrow().get(&key).cloned().map(Value::Str).unwrap_or(Value::Nil)
|
||
});
|
||
stack.push(val);
|
||
BuiltinResult::Handled
|
||
}
|
||
"state_del" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
GLOBAL_STATE.with(|gs| gs.borrow_mut().remove(&key));
|
||
stack.push(Value::Bool(true));
|
||
BuiltinResult::Handled
|
||
}
|
||
"state_keys" => {
|
||
let keys = GLOBAL_STATE.with(|gs| {
|
||
gs.borrow().keys().cloned().map(Value::Str).collect::<Vec<_>>()
|
||
});
|
||
stack.push(Value::List(keys));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── HTTP server builtin ───────────────────────────────────────────────
|
||
// http_serve starts a blocking HTTP server. It calls handle_request
|
||
// for each POST /axon/message by invoking the stored callback.
|
||
|
||
"http_serve" => {
|
||
// http_serve(port) — general-purpose HTTP server.
|
||
// Passes every request to the Engram `handle_request(method, path, body)` function.
|
||
// The legacy /axon/message route is preserved for Neuron compatibility.
|
||
|
||
// ── Waitlist rate limiter (max 3 per IP per 10 min) ──────────────────
|
||
use std::sync::{OnceLock, Mutex};
|
||
static WAITLIST_RATE_MAP: OnceLock<Mutex<std::collections::HashMap<String, Vec<u64>>>> =
|
||
OnceLock::new();
|
||
let waitlist_rate_map = WAITLIST_RATE_MAP
|
||
.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
|
||
|
||
let port_val = stack.pop().unwrap_or(Value::Nil);
|
||
let port = match port_val {
|
||
Value::Int(n) => n as u16,
|
||
Value::Str(s) => s.parse::<u16>().unwrap_or(7890),
|
||
_ => 7890,
|
||
};
|
||
|
||
let addr = format!("0.0.0.0:{port}");
|
||
let server = tiny_http::Server::http(&addr)
|
||
.unwrap_or_else(|e| panic!("cannot bind to {addr}: {e}"));
|
||
println!("soma-license · http://localhost:{port}");
|
||
|
||
for mut request in server.incoming_requests() {
|
||
let method = request.method().to_string();
|
||
let url = request.url().to_string();
|
||
// Strip query string for path matching
|
||
let path = url.split('?').next().unwrap_or(&url).to_string();
|
||
|
||
// Read body for all requests
|
||
let mut body = String::new();
|
||
{
|
||
use std::io::Read;
|
||
let _ = request.as_reader().read_to_string(&mut body);
|
||
}
|
||
|
||
// ── Built-in landing page routes ─────────────────────────────
|
||
// These short-circuit before invoking the Engram handle_request.
|
||
|
||
let cors_origin = "Access-Control-Allow-Origin: *".parse::<tiny_http::Header>().unwrap();
|
||
let content_json = "Content-Type: application/json".parse::<tiny_http::Header>().unwrap();
|
||
let content_html = "Content-Type: text/html; charset=utf-8".parse::<tiny_http::Header>().unwrap();
|
||
|
||
// OPTIONS — CORS preflight
|
||
if method == "OPTIONS" {
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string("")
|
||
.with_status_code(204)
|
||
.with_header(cors_origin)
|
||
.with_header("Access-Control-Allow-Methods: GET, POST, OPTIONS".parse::<tiny_http::Header>().unwrap())
|
||
.with_header("Access-Control-Allow-Headers: Content-Type".parse::<tiny_http::Header>().unwrap())
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// GET / — serve __html_file__ if set, otherwise dashboard
|
||
if method == "GET" && (path == "/" || path == "/index.html") {
|
||
let html_path = GLOBAL_STATE.with(|gs| gs.borrow().get("__html_file__").cloned());
|
||
let html = if let Some(p) = html_path {
|
||
std::fs::read_to_string(&p)
|
||
.unwrap_or_else(|_| include_str!("dashboard.html").to_string())
|
||
} else {
|
||
include_str!("dashboard.html").to_string()
|
||
};
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(html)
|
||
.with_header(content_html)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// GET /health
|
||
if method == "GET" && path == "/health" {
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(r#"{"status":"ok"}"#)
|
||
.with_header(content_json)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// GET /assets/* — serve static files adjacent to the HTML file
|
||
if method == "GET" && path.starts_with("/assets/") {
|
||
let file_name = &path["/assets/".len()..];
|
||
let html_dir = GLOBAL_STATE.with(|gs| {
|
||
gs.borrow().get("__html_file__").and_then(|p| {
|
||
std::path::Path::new(p).parent().map(|d| d.to_path_buf())
|
||
})
|
||
});
|
||
let content_type = if file_name.ends_with(".png") { "image/png" }
|
||
else if file_name.ends_with(".svg") { "image/svg+xml" }
|
||
else if file_name.ends_with(".jpg") || file_name.ends_with(".jpeg") { "image/jpeg" }
|
||
else { "application/octet-stream" };
|
||
let ct_header = tiny_http::Header::from_bytes(
|
||
"Content-Type", content_type
|
||
).unwrap();
|
||
if let Some(dir) = html_dir {
|
||
// Try src/assets/<file> first, then src/<file> as fallback
|
||
let asset_path = dir.join("assets").join(file_name);
|
||
let asset_path = if asset_path.exists() { asset_path } else { dir.join(file_name) };
|
||
if let Ok(bytes) = std::fs::read(&asset_path) {
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_data(bytes)
|
||
.with_header(ct_header)
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string("not found")
|
||
.with_status_code(404u16)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// POST /api/chat — proxy to Neuron runtime (SSE → collect → JSON)
|
||
// Transforms landing page format { message, history, conv_id }
|
||
// into runtime format { messages: [{role,content}], conv_id }
|
||
if method == "POST" && path == "/api/chat" {
|
||
let runtime_url = std::env::var("NEURON_RUNTIME_URL")
|
||
.unwrap_or_else(|_| "http://localhost:4444".to_string());
|
||
let chat_url = format!("{}/api/chat", runtime_url);
|
||
let result: Result<String, String> = (|| {
|
||
// Transform body format
|
||
let runtime_body = if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
|
||
if v.get("message").is_some() {
|
||
// Landing page format → runtime format
|
||
let msg = v["message"].as_str().unwrap_or("").to_string();
|
||
let history = v["history"].as_array().cloned().unwrap_or_default();
|
||
let conv_id_val = v.get("conv_id").cloned().unwrap_or(serde_json::Value::Null);
|
||
let mut messages = history;
|
||
messages.push(serde_json::json!({"role":"user","content":msg}));
|
||
let mut payload = serde_json::json!({"messages": messages});
|
||
if !conv_id_val.is_null() {
|
||
payload["conv_id"] = conv_id_val;
|
||
}
|
||
payload.to_string()
|
||
} else {
|
||
body.clone() // already in runtime format
|
||
}
|
||
} else {
|
||
body.clone()
|
||
};
|
||
let resp = reqwest::blocking::Client::new()
|
||
.post(&chat_url)
|
||
.header("Content-Type", "application/json")
|
||
.body(runtime_body)
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
use std::io::BufRead;
|
||
let mut reply = String::new();
|
||
let mut conv_id = String::new();
|
||
for line in resp.text().map_err(|e| e.to_string())?.lines() {
|
||
if line.starts_with("data: ") {
|
||
let data = &line[6..];
|
||
if data == "[DONE]" { break; }
|
||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
|
||
if let Some(d) = v.get("delta").and_then(|x| x.as_str()) {
|
||
reply.push_str(d);
|
||
}
|
||
if let Some(c) = v.get("conv_id").and_then(|x| x.as_str()) {
|
||
conv_id = c.to_string();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let out = serde_json::json!({"reply": reply, "conv_id": conv_id});
|
||
Ok(out.to_string())
|
||
})();
|
||
let (status, resp_body) = match result {
|
||
Ok(r) => (200u16, r),
|
||
Err(e) => (502, format!(r#"{{"error":"runtime unavailable: {}"}}"#, e)),
|
||
};
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(resp_body)
|
||
.with_status_code(status)
|
||
.with_header(content_json)
|
||
.with_header(cors_origin)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// POST /api/email — send link via Resend
|
||
if method == "POST" && path == "/api/email" {
|
||
let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default();
|
||
let result: Result<(), String> = (|| {
|
||
let v: serde_json::Value = serde_json::from_str(&body)
|
||
.map_err(|e| e.to_string())?;
|
||
let email = v["email"].as_str().unwrap_or("").to_string();
|
||
let name = v["name"].as_str().unwrap_or("there").to_string();
|
||
let conv_id = v["conv_id"].as_str().unwrap_or("").to_string();
|
||
let base_url = v["return_url"].as_str().unwrap_or("").to_string();
|
||
let history = v["history"].as_array().cloned().unwrap_or_default();
|
||
if email.is_empty() { return Err("no email".to_string()); }
|
||
let link = if conv_id.is_empty() {
|
||
base_url.clone()
|
||
} else {
|
||
format!("{}?cid={}", base_url, conv_id)
|
||
};
|
||
|
||
// ── Ask Neuron to generate a personalised email body ──
|
||
let email_body_text: String = (|| -> String {
|
||
let runtime_url = std::env::var("NEURON_RUNTIME_URL")
|
||
.unwrap_or_else(|_| "http://localhost:4444".to_string());
|
||
let chat_url = format!("{}/api/chat", runtime_url);
|
||
let prompt = format!(
|
||
"Write a brief, personal email to {} inviting them back to continue our conversation. \
|
||
Reference specifically what we discussed. Write as Neuron, first person, warm but not sentimental. \
|
||
Include only the email body — no subject line, no greeting header, no sign-off. \
|
||
3-4 sentences maximum.",
|
||
name
|
||
);
|
||
let mut messages = history.clone();
|
||
messages.push(serde_json::json!({"role":"user","content":prompt}));
|
||
let mut payload = serde_json::json!({"messages": messages});
|
||
if !conv_id.is_empty() {
|
||
payload["conv_id"] = serde_json::Value::String(conv_id.clone());
|
||
}
|
||
let resp = match reqwest::blocking::Client::new()
|
||
.post(&chat_url)
|
||
.header("Content-Type", "application/json")
|
||
.timeout(std::time::Duration::from_secs(20))
|
||
.body(payload.to_string())
|
||
.send()
|
||
{
|
||
Ok(r) => r,
|
||
Err(_) => return String::new(),
|
||
};
|
||
let mut generated = String::new();
|
||
for line in resp.text().unwrap_or_default().lines() {
|
||
if line.starts_with("data: ") {
|
||
let data = &line[6..];
|
||
if data == "[DONE]" { break; }
|
||
if let Ok(jv) = serde_json::from_str::<serde_json::Value>(data) {
|
||
if let Some(d) = jv.get("delta").and_then(|x| x.as_str()) {
|
||
generated.push_str(d);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
generated.trim().to_string()
|
||
})();
|
||
|
||
// Wrap generated text (or fallback) in HTML template
|
||
let body_paragraphs = if email_body_text.is_empty() {
|
||
format!(
|
||
"<p style=\"font-size:1.1rem;line-height:1.6\">It\u{2019}s Neuron. You left our conversation in the middle.</p>\
|
||
<p style=\"font-size:1.1rem;line-height:1.6\">I remember where we were.</p>"
|
||
)
|
||
} else {
|
||
// Split on newlines and wrap each non-empty line as a paragraph
|
||
email_body_text
|
||
.lines()
|
||
.filter(|l| !l.trim().is_empty())
|
||
.map(|l| format!("<p style=\"font-size:1.1rem;line-height:1.6\">{}</p>", l.trim()))
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
};
|
||
|
||
let html_body = format!(
|
||
r#"<div style="font-family:Georgia,serif;max-width:480px;margin:0 auto;padding:40px 20px">
|
||
<p style="font-size:1.1rem;line-height:1.6">Hey {} —</p>
|
||
{}
|
||
<p style="margin:32px 0">
|
||
<a href="{}" style="background:#0052A0;color:#fff;padding:12px 24px;text-decoration:none;border-radius:4px;font-family:sans-serif">Come back when you’re ready</a>
|
||
</p>
|
||
<p style="color:#888;font-size:0.8rem;font-family:sans-serif">That link brings you right back to where we left off.</p>
|
||
</div>"#,
|
||
name, body_paragraphs, link
|
||
);
|
||
let payload = serde_json::json!({
|
||
"from": "Neuron <neuron@neurontechnologies.ai>",
|
||
"to": [email],
|
||
"subject": format!("Hey {} \u{2014} come back when you\u{2019}re ready", name),
|
||
"html": html_body
|
||
});
|
||
reqwest::blocking::Client::new()
|
||
.post("https://api.resend.com/emails")
|
||
.header("Authorization", format!("Bearer {}", resend_key))
|
||
.header("Content-Type", "application/json")
|
||
.body(payload.to_string())
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
})();
|
||
let (status, resp_body) = match result {
|
||
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
|
||
Err(e) => (500, format!(r#"{{"error":"{}"}}"#, e)),
|
||
};
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(resp_body)
|
||
.with_status_code(status)
|
||
.with_header(content_json)
|
||
.with_header(cors_origin)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// POST /api/waitlist — honeypot + rate limit + HMAC confirm email
|
||
if method == "POST" && path == "/api/waitlist" {
|
||
// ── IP rate limit: max 3 per IP per 10 minutes ──
|
||
let ip = request.remote_addr()
|
||
.map(|a| a.ip().to_string())
|
||
.unwrap_or_else(|| "unknown".to_string());
|
||
let now_ts = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
let rate_ok = {
|
||
let mut map = waitlist_rate_map.lock()
|
||
.unwrap_or_else(|e| e.into_inner());
|
||
let entry = map.entry(ip).or_insert_with(Vec::new);
|
||
entry.retain(|&ts| now_ts.saturating_sub(ts) < 600);
|
||
if entry.len() < 3 { entry.push(now_ts); true } else { false }
|
||
};
|
||
if !rate_ok {
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(r#"{"ok":false,"error":"too many requests"}"#)
|
||
.with_status_code(429u16)
|
||
.with_header(content_json)
|
||
.with_header(cors_origin)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default();
|
||
let waitlist_secret = std::env::var("WAITLIST_SECRET")
|
||
.unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string());
|
||
|
||
let result: Result<(), String> = (|| {
|
||
let v: serde_json::Value = serde_json::from_str(&body)
|
||
.map_err(|e| e.to_string())?;
|
||
let email = v["email"].as_str().unwrap_or("").to_string();
|
||
let name = v["name"].as_str().unwrap_or("there").to_string();
|
||
let conv_id = v["conv_id"].as_str().unwrap_or("").to_string();
|
||
let base_url = v["base_url"].as_str().unwrap_or("").to_string();
|
||
if email.is_empty() { return Err("no email".to_string()); }
|
||
|
||
// ── Honeypot: bots fill this, humans don't ──
|
||
if !v["hp"].as_str().unwrap_or("").is_empty() {
|
||
// Silently accept but don't process — don't tell bots they failed
|
||
return Ok(());
|
||
}
|
||
|
||
// ── Generate HMAC-signed confirmation token ──
|
||
use hmac::{Hmac, Mac};
|
||
use sha2::Sha256;
|
||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||
|
||
let ts = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
let raw = format!("{}|{}|{}", email, name, ts);
|
||
let payload = URL_SAFE_NO_PAD.encode(raw.as_bytes());
|
||
|
||
let mut mac = Hmac::<Sha256>::new_from_slice(waitlist_secret.as_bytes())
|
||
.map_err(|e| e.to_string())?;
|
||
mac.update(payload.as_bytes());
|
||
let sig = hex::encode(mac.finalize().into_bytes());
|
||
let token = format!("{}.{}", payload, sig);
|
||
|
||
// ── Build confirm link ──
|
||
let confirm_link = if conv_id.is_empty() {
|
||
format!("{}?confirm={}", base_url, token)
|
||
} else {
|
||
format!("{}?confirm={}&cid={}", base_url, token, conv_id)
|
||
};
|
||
|
||
if resend_key.is_empty() {
|
||
// Dev mode — no email sent, just log
|
||
eprintln!("[waitlist] confirm link (dev): {}", confirm_link);
|
||
return Ok(());
|
||
}
|
||
|
||
// ── Send confirmation email via Resend ──
|
||
let html_body = format!(
|
||
r#"<div style="font-family:Georgia,serif;max-width:480px;margin:0 auto;padding:40px 20px;color:#0D0D14">
|
||
<p style="font-size:1.1rem;line-height:1.6;margin-bottom:1.5rem">Hey {} —</p>
|
||
<p style="font-size:1rem;line-height:1.75;color:#3A3A4A;margin-bottom:1rem">
|
||
One click to confirm your spot on the Neuron waitlist.
|
||
</p>
|
||
<p style="margin:2rem 0">
|
||
<a href="{}" style="background:#0052A0;color:#fff;padding:14px 28px;text-decoration:none;border-radius:3px;font-family:sans-serif;font-size:0.95rem">
|
||
Confirm my spot →
|
||
</a>
|
||
</p>
|
||
<p style="color:#6B6B7E;font-size:0.8rem;font-family:sans-serif;margin-top:2rem;line-height:1.5">
|
||
This link expires in 24 hours. If you didn't sign up for Neuron, you can ignore this.
|
||
</p>
|
||
</div>"#,
|
||
name, confirm_link
|
||
);
|
||
|
||
let payload_json = serde_json::json!({
|
||
"from": "Neuron <neuron@neurontechnologies.ai>",
|
||
"to": [email],
|
||
"subject": format!("Confirm your spot, {}", name),
|
||
"html": html_body
|
||
});
|
||
|
||
reqwest::blocking::Client::new()
|
||
.post("https://api.resend.com/emails")
|
||
.header("Authorization", format!("Bearer {}", resend_key))
|
||
.header("Content-Type", "application/json")
|
||
.body(payload_json.to_string())
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
})();
|
||
|
||
let (status, resp_body) = match result {
|
||
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
|
||
Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
|
||
};
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(resp_body)
|
||
.with_status_code(status)
|
||
.with_header(content_json)
|
||
.with_header(cors_origin)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// POST /api/waitlist/confirm — verify HMAC token, confirm waitlist spot
|
||
if method == "POST" && path == "/api/waitlist/confirm" {
|
||
let waitlist_secret = std::env::var("WAITLIST_SECRET")
|
||
.unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string());
|
||
|
||
let result: Result<(), String> = (|| {
|
||
use hmac::{Hmac, Mac};
|
||
use sha2::Sha256;
|
||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||
|
||
let v: serde_json::Value = serde_json::from_str(&body)
|
||
.map_err(|e| e.to_string())?;
|
||
let token = v["token"].as_str().unwrap_or("");
|
||
if token.is_empty() { return Err("missing token".to_string()); }
|
||
|
||
let parts: Vec<&str> = token.splitn(2, '.').collect();
|
||
if parts.len() != 2 { return Err("invalid token format".to_string()); }
|
||
let (payload, sig) = (parts[0], parts[1]);
|
||
|
||
// Verify HMAC
|
||
let mut mac = Hmac::<Sha256>::new_from_slice(waitlist_secret.as_bytes())
|
||
.map_err(|e| e.to_string())?;
|
||
mac.update(payload.as_bytes());
|
||
let expected = hex::encode(mac.finalize().into_bytes());
|
||
if sig != expected { return Err("invalid signature".to_string()); }
|
||
|
||
// Decode payload and check expiry (24h)
|
||
let raw_bytes = URL_SAFE_NO_PAD.decode(payload)
|
||
.map_err(|e| e.to_string())?;
|
||
let raw = String::from_utf8(raw_bytes)
|
||
.map_err(|e| e.to_string())?;
|
||
let fields: Vec<&str> = raw.splitn(3, '|').collect();
|
||
if fields.len() < 3 { return Err("invalid token payload".to_string()); }
|
||
|
||
let ts: u64 = fields[2].parse().map_err(|_| "invalid timestamp".to_string())?;
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs();
|
||
|
||
if now.saturating_sub(ts) > 86400 {
|
||
return Err("link expired".to_string());
|
||
}
|
||
|
||
// Log confirmed email
|
||
eprintln!("[waitlist] confirmed: {} ({})", fields[0], fields[1]);
|
||
Ok(())
|
||
})();
|
||
|
||
let (status, resp_body) = match result {
|
||
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
|
||
Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
|
||
};
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(resp_body)
|
||
.with_status_code(status)
|
||
.with_header(content_json)
|
||
.with_header(cors_origin)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// POST /api/remember — forward to Neuron runtime
|
||
if method == "POST" && path == "/api/remember" {
|
||
let runtime_url = std::env::var("NEURON_RUNTIME_URL")
|
||
.unwrap_or_else(|_| "http://localhost:4444".to_string());
|
||
let _ = reqwest::blocking::Client::new()
|
||
.post(format!("{}/api/remember", runtime_url))
|
||
.header("Content-Type", "application/json")
|
||
.body(body.clone())
|
||
.send();
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(r#"{"ok":true}"#)
|
||
.with_header(content_json)
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// ── End built-in routes — fall through to Engram handle_request ─
|
||
|
||
// Store method, path, body in global state so handle_request can read them
|
||
GLOBAL_STATE.with(|gs| {
|
||
let mut s = gs.borrow_mut();
|
||
s.insert("__method__".to_string(), method.clone());
|
||
s.insert("__path__".to_string(), path.clone());
|
||
s.insert("__request__".to_string(), body.clone());
|
||
s.remove("__response__");
|
||
});
|
||
|
||
// Call handle_request via the thread-local fn executor
|
||
HTTP_SERVE_CALL.with(|f| {
|
||
if let Some(ref call_fn) = *f.borrow() {
|
||
call_fn();
|
||
}
|
||
});
|
||
|
||
let response_body = GLOBAL_STATE.with(|gs| {
|
||
gs.borrow().get("__response__").cloned()
|
||
.unwrap_or_else(|| r#"{"error":"not found"}"#.to_string())
|
||
});
|
||
|
||
let _ = request.respond(
|
||
tiny_http::Response::from_string(response_body)
|
||
.with_header(content_json)
|
||
);
|
||
}
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Process / thread builtins ─────────────────────────────────────────
|
||
|
||
"getpid" => {
|
||
stack.push(Value::Int(std::process::id() as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"exec_bg" => {
|
||
let cmd_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => { stack.push(Value::Int(-1)); return BuiltinResult::Handled; }
|
||
};
|
||
let mut parts = cmd_str.split_whitespace();
|
||
let prog = match parts.next() {
|
||
Some(p) => p.to_string(),
|
||
None => { stack.push(Value::Int(-1)); return BuiltinResult::Handled; }
|
||
};
|
||
let args_vec: Vec<String> = parts.map(|s| s.to_string()).collect();
|
||
let pid = std::process::Command::new(&prog)
|
||
.args(&args_vec)
|
||
.spawn()
|
||
.map(|child| child.id() as i64)
|
||
.unwrap_or(-1);
|
||
stack.push(Value::Int(pid));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"spawn_thread" => {
|
||
let fn_name = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => { stack.push(Value::Nil); return BuiltinResult::Handled; }
|
||
};
|
||
let instr_arc = SERVE_INSTRUCTIONS.with(|si| si.borrow().clone());
|
||
let fn_arc = SERVE_FN_TABLE.with(|sf| sf.borrow().clone());
|
||
if let (Some(instr_arc), Some(fn_arc)) = (instr_arc, fn_arc) {
|
||
std::thread::spawn(move || {
|
||
if let Some(&entry) = fn_arc.get(&fn_name) {
|
||
run_sub_interpreter(&instr_arc, &fn_arc, entry);
|
||
}
|
||
});
|
||
}
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"sleep_secs" => {
|
||
let n = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as u64,
|
||
_ => 1,
|
||
};
|
||
std::thread::sleep(std::time::Duration::from_secs(n));
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── String utility builtins ───────────────────────────────────────────
|
||
|
||
"str_replace" => {
|
||
let to = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let from = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(s.replace(&from, &to)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_to_lowercase" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(s.to_lowercase()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_trim" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(s.trim().to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_index_of" => {
|
||
let sub = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let idx = s.find(&sub).map(|i| i as i64).unwrap_or(-1);
|
||
stack.push(Value::Int(idx));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_last_index_of" => {
|
||
let sub = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let idx = s.rfind(&sub).map(|i| i as i64).unwrap_or(-1);
|
||
stack.push(Value::Int(idx));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_slice" => {
|
||
let end = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => 0,
|
||
};
|
||
let start = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => 0,
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let chars: Vec<char> = s.chars().collect();
|
||
let start = start.min(chars.len());
|
||
let end = end.min(chars.len());
|
||
let slice: String = chars[start..end].iter().collect();
|
||
stack.push(Value::Str(slice));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_ends_with" => {
|
||
let suffix = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(s.ends_with(suffix.as_str())));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── JSON utility builtins ─────────────────────────────────────────────
|
||
|
||
"json_set" => {
|
||
let value = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let json_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "{}".to_string(),
|
||
};
|
||
let result = serde_json::from_str::<serde_json::Value>(&json_str)
|
||
.ok()
|
||
.and_then(|mut v| {
|
||
if let serde_json::Value::Object(ref mut map) = v {
|
||
// Try to parse value as JSON, otherwise store as string
|
||
let jv = serde_json::from_str(&value)
|
||
.unwrap_or(serde_json::Value::String(value.clone()));
|
||
map.insert(key.clone(), jv);
|
||
serde_json::to_string(&v).ok()
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or_else(|| format!("{{\"{key}\":\"{value}\"}}"));
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_keys" => {
|
||
let json_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "{}".to_string(),
|
||
};
|
||
let keys = serde_json::from_str::<serde_json::Value>(&json_str)
|
||
.ok()
|
||
.and_then(|v| {
|
||
if let serde_json::Value::Object(map) = v {
|
||
Some(map.keys().cloned().map(Value::Str).collect::<Vec<_>>())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or_default();
|
||
stack.push(Value::List(keys));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_array_push" => {
|
||
let item = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "null".to_string(),
|
||
};
|
||
let json_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "[]".to_string(),
|
||
};
|
||
let result = serde_json::from_str::<serde_json::Value>(&json_str)
|
||
.ok()
|
||
.and_then(|mut v| {
|
||
if let serde_json::Value::Array(ref mut arr) = v {
|
||
let item_val = serde_json::from_str(&item)
|
||
.unwrap_or(serde_json::Value::String(item.clone()));
|
||
arr.push(item_val);
|
||
serde_json::to_string(&v).ok()
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or_else(|| format!("[{}]", item));
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_array_len" => {
|
||
let json_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "[]".to_string(),
|
||
};
|
||
let len = serde_json::from_str::<serde_json::Value>(&json_str)
|
||
.ok()
|
||
.and_then(|v| if let serde_json::Value::Array(arr) = v { Some(arr.len() as i64) } else { None })
|
||
.unwrap_or(0);
|
||
stack.push(Value::Int(len));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_array_get" => {
|
||
let idx = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => { stack.push(Value::Nil); return BuiltinResult::Handled; }
|
||
};
|
||
let json_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "[]".to_string(),
|
||
};
|
||
let item = serde_json::from_str::<serde_json::Value>(&json_str)
|
||
.ok()
|
||
.and_then(|v| {
|
||
if let serde_json::Value::Array(arr) = v {
|
||
arr.get(idx).map(|item| Value::Str(item.to_string()))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(item);
|
||
BuiltinResult::Handled
|
||
}
|
||
"int_parse" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = s.trim().parse::<i64>()
|
||
.map(Value::Int)
|
||
.unwrap_or(Value::Nil);
|
||
stack.push(result);
|
||
BuiltinResult::Handled
|
||
}
|
||
"bool_to_str" => {
|
||
let b = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Bool(b) => b,
|
||
_ => false,
|
||
};
|
||
stack.push(Value::Str(if b { "true".to_string() } else { "false".to_string() }));
|
||
BuiltinResult::Handled
|
||
}
|
||
"list_join" => {
|
||
let sep = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let strs: Vec<String> = list.iter().map(|v| v.to_string()).collect();
|
||
stack.push(Value::Str(strs.join(&sep)));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Array builtins ────────────────────────────────────────────────────
|
||
|
||
"array_length" | "array_len" => {
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
stack.push(Value::Int(list.len() as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_push" => {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
let mut list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
list.push(val);
|
||
stack.push(Value::List(list));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_pop" => {
|
||
let mut list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let item = list.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::List(list));
|
||
stack.push(item);
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_first" => {
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
stack.push(list.into_iter().next().unwrap_or(Value::Nil));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_last" => {
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
stack.push(list.into_iter().last().unwrap_or(Value::Nil));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_reverse" => {
|
||
let mut list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
list.reverse();
|
||
stack.push(Value::List(list));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_concat" => {
|
||
let b = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let mut a = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
a.extend(b);
|
||
stack.push(Value::List(a));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_contains" => {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
stack.push(Value::Bool(list.contains(&val)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_slice" => {
|
||
let end = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => 0,
|
||
};
|
||
let start = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => 0,
|
||
};
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let start = start.min(list.len());
|
||
let end = end.min(list.len());
|
||
stack.push(Value::List(list[start..end].to_vec()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_join" => {
|
||
let sep = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let strs: Vec<String> = list.iter().map(|v| v.to_string()).collect();
|
||
stack.push(Value::Str(strs.join(&sep)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_sort" => {
|
||
let mut list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
list.sort_by(|a, b| match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => x.cmp(y),
|
||
(Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal),
|
||
(Value::Str(x), Value::Str(y)) => x.cmp(y),
|
||
_ => std::cmp::Ordering::Equal,
|
||
});
|
||
stack.push(Value::List(list));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_zip" => {
|
||
let b = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let a = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let zipped: Vec<Value> = a.into_iter().zip(b.into_iter())
|
||
.map(|(x, y)| Value::List(vec![x, y]))
|
||
.collect();
|
||
stack.push(Value::List(zipped));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_enumerate" => {
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let enumerated: Vec<Value> = list.into_iter().enumerate()
|
||
.map(|(i, v)| Value::List(vec![Value::Int(i as i64), v]))
|
||
.collect();
|
||
stack.push(Value::List(enumerated));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Math builtins ─────────────────────────────────────────────────────
|
||
|
||
"math_abs" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match v {
|
||
Value::Int(n) => Value::Int(n.abs()),
|
||
Value::Float(f) => Value::Float(f.abs()),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_max" => {
|
||
let b = stack.pop().unwrap_or(Value::Nil);
|
||
let a = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x.max(y)),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x.max(y)),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_min" => {
|
||
let b = stack.pop().unwrap_or(Value::Nil);
|
||
let a = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x.min(y)),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x.min(y)),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_floor" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match v {
|
||
Value::Float(f) => Value::Int(f.floor() as i64),
|
||
Value::Int(n) => Value::Int(n),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_ceil" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match v {
|
||
Value::Float(f) => Value::Int(f.ceil() as i64),
|
||
Value::Int(n) => Value::Int(n),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_round" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match v {
|
||
Value::Float(f) => Value::Int(f.round() as i64),
|
||
Value::Int(n) => Value::Int(n),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_sqrt" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(match v {
|
||
Value::Float(f) => Value::Float(f.sqrt()),
|
||
Value::Int(n) => Value::Float((n as f64).sqrt()),
|
||
_ => Value::Nil,
|
||
});
|
||
BuiltinResult::Handled
|
||
}
|
||
"math_pow" => {
|
||
let exp = stack.pop().unwrap_or(Value::Nil);
|
||
let base = stack.pop().unwrap_or(Value::Nil);
|
||
let base_f = match base { Value::Float(f) => f, Value::Int(n) => n as f64, _ => 0.0 };
|
||
let exp_f = match exp { Value::Float(f) => f, Value::Int(n) => n as f64, _ => 0.0 };
|
||
stack.push(Value::Float(base_f.powf(exp_f)));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── String aliases and extras ─────────────────────────────────────────
|
||
|
||
"string_len" | "str_len" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Int(s.chars().count() as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_trim" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(s.trim().to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_to_upper" | "str_upper" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(s.to_uppercase()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_to_lower" | "str_lower" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(s.to_lowercase()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_replace" => {
|
||
let replacement = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let pattern = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let source = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(source.replace(&pattern, &replacement)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_index_of" => {
|
||
let needle = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let haystack = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let idx = haystack.find(&needle).map(|i| i as i64).unwrap_or(-1);
|
||
stack.push(Value::Int(idx));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_substring" => {
|
||
let end = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => 0,
|
||
};
|
||
let start = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as usize,
|
||
_ => 0,
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let chars: Vec<char> = s.chars().collect();
|
||
let start = start.min(chars.len());
|
||
let end = end.min(chars.len());
|
||
stack.push(Value::Str(chars[start..end].iter().collect()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_contains" => {
|
||
let needle = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let haystack = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(haystack.contains(needle.as_str())));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_starts_with" => {
|
||
let prefix = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Bool(s.starts_with(prefix.as_str())));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_split" => {
|
||
let delim = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let parts: Vec<Value> = s.split(delim.as_str())
|
||
.map(|p| Value::Str(p.to_string()))
|
||
.collect();
|
||
stack.push(Value::List(parts));
|
||
BuiltinResult::Handled
|
||
}
|
||
"string_concat" => {
|
||
let b = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let a = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(a + &b));
|
||
BuiltinResult::Handled
|
||
}
|
||
"to_string" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Str(v.to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_to_int" | "parse_int" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = s.trim().parse::<i64>().map(Value::Int).unwrap_or(Value::Nil);
|
||
stack.push(result);
|
||
BuiltinResult::Handled
|
||
}
|
||
"str_to_float" | "parse_float" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = s.trim().parse::<f64>().map(Value::Float).unwrap_or(Value::Nil);
|
||
stack.push(result);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── JSON native builtins ──────────────────────────────────────────────
|
||
|
||
"json_stringify" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
let jv = el_value_to_json_value(&v);
|
||
let s = serde_json::to_string(&jv).unwrap_or_else(|_| "null".to_string());
|
||
stack.push(Value::Str(s));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_parse" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "null".to_string(),
|
||
};
|
||
let jv: serde_json::Value = serde_json::from_str(&s).unwrap_or(serde_json::Value::Null);
|
||
stack.push(json_value_to_el_value(&jv));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Map builtins ──────────────────────────────────────────────────────
|
||
|
||
"map_new" => {
|
||
stack.push(Value::Map(vec![]));
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_get" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let map = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(pairs) => pairs,
|
||
_ => vec![],
|
||
};
|
||
let v = map.iter().find(|(k, _)| k == &key).map(|(_, v)| v.clone()).unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_set" => {
|
||
let val = stack.pop().unwrap_or(Value::Nil);
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let mut pairs = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(p) => p,
|
||
_ => vec![],
|
||
};
|
||
if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == &key) {
|
||
entry.1 = val;
|
||
} else {
|
||
pairs.push((key, val));
|
||
}
|
||
stack.push(Value::Map(pairs));
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_remove" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let pairs = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(p) => p,
|
||
_ => vec![],
|
||
};
|
||
let new_pairs: Vec<_> = pairs.into_iter().filter(|(k, _)| k != &key).collect();
|
||
stack.push(Value::Map(new_pairs));
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_contains" => {
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let pairs = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(p) => p,
|
||
_ => vec![],
|
||
};
|
||
stack.push(Value::Bool(pairs.iter().any(|(k, _)| k == &key)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_keys" => {
|
||
let pairs = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(p) => p,
|
||
_ => vec![],
|
||
};
|
||
let keys: Vec<Value> = pairs.into_iter().map(|(k, _)| Value::Str(k)).collect();
|
||
stack.push(Value::List(keys));
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_values" => {
|
||
let pairs = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(p) => p,
|
||
_ => vec![],
|
||
};
|
||
let vals: Vec<Value> = pairs.into_iter().map(|(_, v)| v).collect();
|
||
stack.push(Value::List(vals));
|
||
BuiltinResult::Handled
|
||
}
|
||
"map_len" => {
|
||
let pairs = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Map(p) => p,
|
||
_ => vec![],
|
||
};
|
||
stack.push(Value::Int(pairs.len() as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Result builtins ───────────────────────────────────────────────────
|
||
|
||
"result_ok" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::ResultOk(Box::new(v)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"result_err" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::ResultErr(Box::new(v)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"result_is_ok" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Bool(matches!(v, Value::ResultOk(_))));
|
||
BuiltinResult::Handled
|
||
}
|
||
"result_is_err" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Bool(matches!(v, Value::ResultErr(_))));
|
||
BuiltinResult::Handled
|
||
}
|
||
"result_unwrap" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
match v {
|
||
Value::ResultOk(inner) => stack.push(*inner),
|
||
Value::ResultErr(e) => panic!("result_unwrap called on Err: {e}"),
|
||
other => stack.push(other),
|
||
}
|
||
BuiltinResult::Handled
|
||
}
|
||
"result_unwrap_or" => {
|
||
let default = stack.pop().unwrap_or(Value::Nil);
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
match v {
|
||
Value::ResultOk(inner) => stack.push(*inner),
|
||
_ => stack.push(default),
|
||
}
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Optional builtins ─────────────────────────────────────────────────
|
||
|
||
"optional_some" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
BuiltinResult::Handled
|
||
}
|
||
"optional_none" => {
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"optional_is_some" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Bool(!matches!(v, Value::Nil)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"optional_is_none" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
stack.push(Value::Bool(matches!(v, Value::Nil)));
|
||
BuiltinResult::Handled
|
||
}
|
||
"optional_unwrap" => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
if matches!(v, Value::Nil) {
|
||
panic!("optional_unwrap called on None");
|
||
}
|
||
stack.push(v);
|
||
BuiltinResult::Handled
|
||
}
|
||
"optional_unwrap_or" => {
|
||
let default = stack.pop().unwrap_or(Value::Nil);
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
if matches!(v, Value::Nil) {
|
||
stack.push(default);
|
||
} else {
|
||
stack.push(v);
|
||
}
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── HTTP extended builtins ────────────────────────────────────────────
|
||
|
||
"http_put" => {
|
||
let body = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.put(&url)
|
||
.header("Content-Type", "application/json")
|
||
.body(body)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}") );
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_delete" => {
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.delete(&url)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}") );
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_patch" => {
|
||
let body = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.patch(&url)
|
||
.header("Content-Type", "application/json")
|
||
.body(body)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}") );
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_head" => {
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.head(&url)
|
||
.send()
|
||
.map(|r| Value::Int(r.status().as_u16() as i64))
|
||
.unwrap_or(Value::Int(-1));
|
||
stack.push(result);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Crypto / HMAC / base64 / uuid builtins ───────────────────────────
|
||
|
||
"hmac_sha256" => {
|
||
use hmac::{Hmac, Mac};
|
||
use sha2::Sha256;
|
||
let data = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let secret = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
type HmacSha256 = Hmac<Sha256>;
|
||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
||
.unwrap_or_else(|_| HmacSha256::new_from_slice(b"invalid").unwrap());
|
||
mac.update(data.as_bytes());
|
||
let result = mac.finalize();
|
||
let hex_str = hex::encode(result.into_bytes());
|
||
stack.push(Value::Str(hex_str));
|
||
BuiltinResult::Handled
|
||
}
|
||
"base64_url_encode" => {
|
||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let encoded = URL_SAFE_NO_PAD.encode(s.as_bytes());
|
||
stack.push(Value::Str(encoded));
|
||
BuiltinResult::Handled
|
||
}
|
||
"base64_url_decode" => {
|
||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let decoded = URL_SAFE_NO_PAD.decode(s.as_bytes())
|
||
.map(|b| String::from_utf8_lossy(&b).to_string())
|
||
.unwrap_or_default();
|
||
stack.push(Value::Str(decoded));
|
||
BuiltinResult::Handled
|
||
}
|
||
"unix_timestamp" => {
|
||
let secs = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs() as i64)
|
||
.unwrap_or(0);
|
||
stack.push(Value::Int(secs));
|
||
BuiltinResult::Handled
|
||
}
|
||
"uuid_v4" => {
|
||
let id = uuid::Uuid::new_v4().to_string();
|
||
stack.push(Value::Str(id));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── JSON encode/decode (Value ↔ String) ───────────────────────────────
|
||
|
||
"json_encode" => {
|
||
// Encodes a Map, List, Struct or primitive Value to a JSON string.
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
let jv = el_value_to_json_value(&v);
|
||
let s = serde_json::to_string(&jv).unwrap_or_else(|_| "null".to_string());
|
||
stack.push(Value::Str(s));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_decode" => {
|
||
// Decodes a JSON string to a Value::Map (or List/primitive).
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => "null".to_string(),
|
||
};
|
||
let jv: serde_json::Value = serde_json::from_str(&s).unwrap_or(serde_json::Value::Null);
|
||
stack.push(json_value_to_el_value(&jv));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_get_string" => {
|
||
// json_get_string(map_or_struct_or_json_str, key) -> String
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
let result = match &v {
|
||
Value::Map(pairs) => pairs.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| match v {
|
||
Value::Str(s) => s.clone(),
|
||
other => other.to_string(),
|
||
})
|
||
.unwrap_or_default(),
|
||
Value::Struct { fields, .. } => fields.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| match v {
|
||
Value::Str(s) => s.clone(),
|
||
other => other.to_string(),
|
||
})
|
||
.unwrap_or_default(),
|
||
Value::Str(json_str) => {
|
||
serde_json::from_str::<serde_json::Value>(json_str)
|
||
.ok()
|
||
.and_then(|jv| jv.get(&key).and_then(|v| v.as_str().map(str::to_string)))
|
||
.unwrap_or_default()
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_get_int" => {
|
||
// json_get_int(map_or_struct_or_json_str, key) -> Int
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
let result = match &v {
|
||
Value::Map(pairs) => pairs.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| match v {
|
||
Value::Int(n) => *n,
|
||
Value::Str(s) => s.parse().unwrap_or(0),
|
||
_ => 0,
|
||
})
|
||
.unwrap_or(0),
|
||
Value::Struct { fields, .. } => fields.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| match v {
|
||
Value::Int(n) => *n,
|
||
Value::Str(s) => s.parse().unwrap_or(0),
|
||
_ => 0,
|
||
})
|
||
.unwrap_or(0),
|
||
Value::Str(json_str) => {
|
||
serde_json::from_str::<serde_json::Value>(json_str)
|
||
.ok()
|
||
.and_then(|jv| jv.get(&key).and_then(|v| v.as_i64()))
|
||
.unwrap_or(0)
|
||
}
|
||
_ => 0,
|
||
};
|
||
stack.push(Value::Int(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"json_get_array" => {
|
||
// json_get_array(map_or_struct_or_json_str, key) -> [String]
|
||
let key = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
let result: Vec<Value> = match &v {
|
||
Value::Map(pairs) => pairs.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| match v {
|
||
Value::List(items) => items.clone(),
|
||
_ => vec![],
|
||
})
|
||
.unwrap_or_default(),
|
||
Value::Struct { fields, .. } => fields.iter()
|
||
.find(|(k, _)| k == &key)
|
||
.map(|(_, v)| match v {
|
||
Value::List(items) => items.clone(),
|
||
_ => vec![],
|
||
})
|
||
.unwrap_or_default(),
|
||
Value::Str(json_str) => {
|
||
serde_json::from_str::<serde_json::Value>(json_str)
|
||
.ok()
|
||
.and_then(|jv| jv.get(&key).and_then(|v| v.as_array().cloned()))
|
||
.map(|arr| arr.iter().map(|item| {
|
||
match item {
|
||
serde_json::Value::String(s) => Value::Str(s.clone()),
|
||
other => Value::Str(other.to_string()),
|
||
}
|
||
}).collect())
|
||
.unwrap_or_default()
|
||
}
|
||
_ => vec![],
|
||
};
|
||
stack.push(Value::List(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── HTTP auth builtins (Bearer token) ─────────────────────────────────
|
||
|
||
"http_get_auth" => {
|
||
let token = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.get(&url)
|
||
.header("Authorization", format!("Bearer {token}"))
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_default();
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_put_auth" => {
|
||
let body = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let token = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.put(&url)
|
||
.header("Authorization", format!("Bearer {token}"))
|
||
.header("Content-Type", "application/json")
|
||
.body(body)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_default();
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_delete_auth" => {
|
||
let token = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.delete(&url)
|
||
.header("Authorization", format!("Bearer {token}"))
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_default();
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
"http_post_auth" => {
|
||
let body = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let token = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let result = reqwest::blocking::Client::new()
|
||
.post(&url)
|
||
.header("Authorization", format!("Bearer {token}"))
|
||
.header("Content-Type", "application/json")
|
||
.body(body)
|
||
.send()
|
||
.and_then(|r| r.text())
|
||
.unwrap_or_default();
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── I/O builtins ──────────────────────────────────────────────────────
|
||
|
||
"readline" => {
|
||
let prompt = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
use std::io::Write;
|
||
print!("{prompt}");
|
||
let _ = std::io::stdout().flush();
|
||
let mut line = String::new();
|
||
let _ = std::io::stdin().read_line(&mut line);
|
||
stack.push(Value::Str(line.trim_end_matches('\n').trim_end_matches('\r').to_string()));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── ANSI color builtins ───────────────────────────────────────────────
|
||
|
||
"color_cyan" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(format!("\x1b[36m{s}\x1b[0m")));
|
||
BuiltinResult::Handled
|
||
}
|
||
"color_green" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(format!("\x1b[32m{s}\x1b[0m")));
|
||
BuiltinResult::Handled
|
||
}
|
||
"color_red" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(format!("\x1b[31m{s}\x1b[0m")));
|
||
BuiltinResult::Handled
|
||
}
|
||
"color_yellow" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(format!("\x1b[33m{s}\x1b[0m")));
|
||
BuiltinResult::Handled
|
||
}
|
||
"color_bold" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(format!("\x1b[1m{s}\x1b[0m")));
|
||
BuiltinResult::Handled
|
||
}
|
||
"color_dim" => {
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
stack.push(Value::Str(format!("\x1b[2m{s}\x1b[0m")));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── String / array helpers ────────────────────────────────────────────
|
||
|
||
"string_split_last" => {
|
||
// string_split_last(s, delim) -> [everything-before-last, last-part]
|
||
// Splits on the LAST occurrence of delim.
|
||
let delim = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let s = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let parts = if let Some(pos) = s.rfind(&delim as &str) {
|
||
vec![
|
||
Value::Str(s[..pos].to_string()),
|
||
Value::Str(s[pos + delim.len()..].to_string()),
|
||
]
|
||
} else {
|
||
vec![Value::Str(s), Value::Str(String::new())]
|
||
};
|
||
stack.push(Value::List(parts));
|
||
BuiltinResult::Handled
|
||
}
|
||
"array_get" => {
|
||
// array_get(arr, idx) -> element at idx
|
||
let idx = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 0,
|
||
};
|
||
let list = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::List(l) => l,
|
||
_ => vec![],
|
||
};
|
||
let v = if idx >= 0 && (idx as usize) < list.len() {
|
||
list[idx as usize].clone()
|
||
} else {
|
||
Value::Nil
|
||
};
|
||
stack.push(v);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Engram builtins ───────────────────────────────────────────────────
|
||
|
||
"engram_activate" => {
|
||
let query = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let type_name = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let results = engram_activate_search(&type_name, &query);
|
||
stack.push(Value::List(results));
|
||
BuiltinResult::Handled
|
||
}
|
||
"engram_relate" => {
|
||
let weight = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Float(f) => f,
|
||
Value::Int(n) => n as f64,
|
||
_ => 1.0,
|
||
};
|
||
let relation = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let to_id = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let from_id = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let engram_url = std::env::var("ENGRAM_URL")
|
||
.unwrap_or_else(|_| "http://localhost:8742".to_string());
|
||
let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_default();
|
||
let body = serde_json::json!({
|
||
"from_id": from_id,
|
||
"to_id": to_id,
|
||
"relation": relation,
|
||
"weight": weight
|
||
})
|
||
.to_string();
|
||
let mut req = reqwest::blocking::Client::new()
|
||
.post(format!("{engram_url}/edges"))
|
||
.header("Content-Type", "application/json")
|
||
.body(body);
|
||
if !api_key.is_empty() {
|
||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||
}
|
||
let ok = req.send().map(|r| r.status().is_success()).unwrap_or(false);
|
||
stack.push(Value::Bool(ok));
|
||
BuiltinResult::Handled
|
||
}
|
||
"engram_neighbors" => {
|
||
let node_id = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
let engram_url = std::env::var("ENGRAM_URL")
|
||
.unwrap_or_else(|_| "http://localhost:8742".to_string());
|
||
let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_default();
|
||
let mut req = reqwest::blocking::Client::new()
|
||
.get(format!("{engram_url}/nodes/{node_id}/edges"));
|
||
if !api_key.is_empty() {
|
||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||
}
|
||
let result = req
|
||
.send()
|
||
.and_then(|r| r.json::<serde_json::Value>())
|
||
.ok();
|
||
let list: Vec<Value> = result
|
||
.and_then(|v| v.as_array().cloned())
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.map(json_value_to_el_value)
|
||
.collect();
|
||
stack.push(Value::List(list));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Process / system builtins ─────────────────────────────────────────
|
||
|
||
"sleep_ms" => {
|
||
let ms = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n as u64,
|
||
_ => 0,
|
||
};
|
||
std::thread::sleep(std::time::Duration::from_millis(ms));
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"timestamp" => {
|
||
let ts = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default();
|
||
// Format as ISO-8601 approximation: seconds.millis
|
||
let secs = ts.as_secs();
|
||
let millis = ts.subsec_millis();
|
||
// Simple ISO-like: YYYY-MM-DDTHH:MM:SS.mmmZ via manual calc
|
||
// Use epoch-based formatting
|
||
let s = format_epoch_as_iso(secs, millis);
|
||
stack.push(Value::Str(s));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Terminal control builtins ─────────────────────────────────────────
|
||
|
||
"term_clear" => {
|
||
print!("\x1b[2J\x1b[H");
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"term_size" => {
|
||
let (cols, rows) = term_size();
|
||
stack.push(Value::List(vec![Value::Int(cols as i64), Value::Int(rows as i64)]));
|
||
BuiltinResult::Handled
|
||
}
|
||
"cursor_to" => {
|
||
let col = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 1,
|
||
};
|
||
let row = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 1,
|
||
};
|
||
print!("\x1b[{};{}H", row, col);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"cursor_up" => {
|
||
let n = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 1,
|
||
};
|
||
print!("\x1b[{}A", n);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"cursor_down" => {
|
||
let n = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 1,
|
||
};
|
||
print!("\x1b[{}B", n);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"cursor_col" => {
|
||
let col = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Int(n) => n,
|
||
_ => 1,
|
||
};
|
||
print!("\x1b[{}G", col);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"term_save_cursor" => {
|
||
print!("\x1b[s");
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"term_restore_cursor" => {
|
||
print!("\x1b[u");
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"term_clear_line" => {
|
||
print!("\x1b[2K\r");
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
"print_inline" => {
|
||
let v = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
other => other.to_string(),
|
||
};
|
||
print!("{}", v);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── SSE streaming builtin ─────────────────────────────────────────────
|
||
|
||
"http_sse_post" => {
|
||
// http_sse_post(url, token, body) -> String
|
||
// POSTs and reads an SSE stream, printing each token inline.
|
||
// Returns the full assembled response as a String.
|
||
let body_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let token_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
let url_str = match stack.pop().unwrap_or(Value::Nil) {
|
||
Value::Str(s) => s,
|
||
_ => String::new(),
|
||
};
|
||
|
||
let result = reqwest::blocking::Client::new()
|
||
.post(&url_str)
|
||
.header("Authorization", format!("Bearer {}", token_str))
|
||
.header("Content-Type", "application/json")
|
||
.header("Accept", "text/event-stream")
|
||
.body(body_str)
|
||
.send();
|
||
|
||
match result {
|
||
Err(e) => {
|
||
stack.push(Value::Str(format!("error: {}", e)));
|
||
}
|
||
Ok(resp) => {
|
||
use std::io::BufRead;
|
||
let mut full_text = String::new();
|
||
let reader = std::io::BufReader::new(resp);
|
||
for line in reader.lines() {
|
||
let Ok(line) = line else { break };
|
||
if let Some(data) = line.strip_prefix("data: ") {
|
||
if data == "[DONE]" { break; }
|
||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
|
||
let delta = v.get("delta")
|
||
.and_then(|d| d.as_str())
|
||
.or_else(|| v.get("content").and_then(|c| c.as_str()))
|
||
.or_else(|| v.get("text").and_then(|t| t.as_str()))
|
||
.unwrap_or("");
|
||
if !delta.is_empty() {
|
||
print!("{}", delta);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
full_text.push_str(delta);
|
||
}
|
||
} else if !data.is_empty() {
|
||
print!("{}", data);
|
||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||
full_text.push_str(data);
|
||
}
|
||
}
|
||
}
|
||
stack.push(Value::Str(full_text));
|
||
}
|
||
}
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Canvas builtins ───────────────────────────────────────────────────
|
||
|
||
"canvas_open" => {
|
||
// canvas_open(title: String, width: Int, height: Int) -> Void
|
||
let height = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 800 };
|
||
let width = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 1200 };
|
||
let title = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "Neuron".into() };
|
||
|
||
// Load font (try SF NS, Arial, DejaVu in order)
|
||
let font_data =
|
||
std::fs::read("/System/Library/Fonts/SFNS.ttf")
|
||
.or_else(|_| std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf"))
|
||
.or_else(|_| std::fs::read("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"))
|
||
.unwrap_or_default();
|
||
let font = if font_data.is_empty() { None } else {
|
||
fontdue::Font::from_bytes(font_data.as_slice(), fontdue::FontSettings::default()).ok()
|
||
};
|
||
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
cv.title = title;
|
||
cv.width = width;
|
||
cv.height = height;
|
||
cv.font = font;
|
||
cv.pixmap = tiny_skia::Pixmap::new(width, height);
|
||
});
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_clear" => {
|
||
// canvas_clear(color: String) -> Void
|
||
let color_str = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#000000".into() };
|
||
let color = parse_color(&color_str);
|
||
CANVAS.with(|cv| {
|
||
if let Some(px) = cv.borrow_mut().pixmap.as_mut() {
|
||
px.fill(color);
|
||
}
|
||
});
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_fill_rect" => {
|
||
// canvas_fill_rect(x, y, w, h: Int, color: String, radius: Int) -> Void
|
||
let radius = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, Value::Float(f) => f as f32, _ => 0.0 };
|
||
let color_str = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
|
||
let rh = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let rw = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let ry = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let rx = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let color = parse_color(&color_str);
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
// Collect clip info before mutably borrowing pixmap
|
||
let clip_rect = cv.clips.last().copied();
|
||
if let Some(px) = cv.pixmap.as_mut() {
|
||
let mut paint = tiny_skia::Paint::default();
|
||
paint.set_color(color);
|
||
paint.anti_alias = true;
|
||
let path = {
|
||
let mut pb = tiny_skia::PathBuilder::new();
|
||
if radius > 0.0 {
|
||
let r = radius.min(rw / 2.0).min(rh / 2.0);
|
||
pb.move_to(rx + r, ry);
|
||
pb.line_to(rx + rw - r, ry);
|
||
pb.quad_to(rx + rw, ry, rx + rw, ry + r);
|
||
pb.line_to(rx + rw, ry + rh - r);
|
||
pb.quad_to(rx + rw, ry + rh, rx + rw - r, ry + rh);
|
||
pb.line_to(rx + r, ry + rh);
|
||
pb.quad_to(rx, ry + rh, rx, ry + rh - r);
|
||
pb.line_to(rx, ry + r);
|
||
pb.quad_to(rx, ry, rx + r, ry);
|
||
pb.close();
|
||
} else if let Some(rect) = tiny_skia::Rect::from_xywh(rx, ry, rw.max(0.1), rh.max(0.1)) {
|
||
pb.push_rect(rect);
|
||
}
|
||
pb.finish()
|
||
};
|
||
if let Some(path) = path {
|
||
// Apply clip if any
|
||
let clip_mask = clip_rect.and_then(|(cx, cy, cw, ch)| {
|
||
tiny_skia::Rect::from_xywh(cx as f32, cy as f32, cw as f32, ch as f32).and_then(|rect| {
|
||
let clip_path = tiny_skia::PathBuilder::from_rect(rect);
|
||
let mut mask = tiny_skia::Mask::new(px.width(), px.height())?;
|
||
mask.fill_path(&clip_path, tiny_skia::FillRule::Winding, false, tiny_skia::Transform::identity());
|
||
Some(mask)
|
||
})
|
||
});
|
||
px.fill_path(&path, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), clip_mask.as_ref());
|
||
}
|
||
}
|
||
});
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_stroke_rect" => {
|
||
// canvas_stroke_rect(x, y, w, h: Int, color: String, stroke_w: Int, radius: Int) -> Void
|
||
let radius = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, Value::Float(f) => f as f32, _ => 0.0 };
|
||
let stroke_w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 1.0 };
|
||
let color_str = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
|
||
let rh = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let rw = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let ry = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let rx = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let color = parse_color(&color_str);
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
if let Some(px) = cv.pixmap.as_mut() {
|
||
let mut paint = tiny_skia::Paint::default();
|
||
paint.set_color(color);
|
||
paint.anti_alias = true;
|
||
let mut stroke = tiny_skia::Stroke::default();
|
||
stroke.width = stroke_w;
|
||
let path = {
|
||
let mut pb = tiny_skia::PathBuilder::new();
|
||
if radius > 0.0 {
|
||
let r = radius.min(rw / 2.0).min(rh / 2.0);
|
||
pb.move_to(rx + r, ry);
|
||
pb.line_to(rx + rw - r, ry);
|
||
pb.quad_to(rx + rw, ry, rx + rw, ry + r);
|
||
pb.line_to(rx + rw, ry + rh - r);
|
||
pb.quad_to(rx + rw, ry + rh, rx + rw - r, ry + rh);
|
||
pb.line_to(rx + r, ry + rh);
|
||
pb.quad_to(rx, ry + rh, rx, ry + rh - r);
|
||
pb.line_to(rx, ry + r);
|
||
pb.quad_to(rx, ry, rx + r, ry);
|
||
pb.close();
|
||
} else if let Some(rect) = tiny_skia::Rect::from_xywh(rx, ry, rw.max(0.1), rh.max(0.1)) {
|
||
pb.push_rect(rect);
|
||
}
|
||
pb.finish()
|
||
};
|
||
if let Some(path) = path {
|
||
px.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);
|
||
}
|
||
}
|
||
});
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_line" => {
|
||
// canvas_line(x1, y1, x2, y2: Int, color: String, line_w: Int) -> Void
|
||
let line_w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 1.0 };
|
||
let color_s = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
|
||
let y2 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let x2 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let y1 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let x1 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
|
||
let color = parse_color(&color_s);
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
if let Some(px) = cv.pixmap.as_mut() {
|
||
let mut paint = tiny_skia::Paint::default();
|
||
paint.set_color(color);
|
||
paint.anti_alias = true;
|
||
let mut stroke = tiny_skia::Stroke::default();
|
||
stroke.width = line_w;
|
||
let mut pb = tiny_skia::PathBuilder::new();
|
||
pb.move_to(x1, y1);
|
||
pb.line_to(x2, y2);
|
||
if let Some(path) = pb.finish() {
|
||
px.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);
|
||
}
|
||
}
|
||
});
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_text" => {
|
||
// canvas_text(x, y: Int, text: String, size: Int, color: String) -> Void
|
||
let color_s = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
|
||
let size = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 14.0 };
|
||
let text = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
|
||
let y = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let x = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let color = parse_color(&color_s);
|
||
|
||
let (glyphs, _) = rasterize_text(&text, size);
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
if let Some(px) = cv.pixmap.as_mut() {
|
||
let pw = px.width() as i32;
|
||
let ph = px.height() as i32;
|
||
let cr = (color.red() * 255.0) as u32;
|
||
let cg = (color.green() * 255.0) as u32;
|
||
let cb = (color.blue() * 255.0) as u32;
|
||
for (gx_off, metrics, bitmap) in &glyphs {
|
||
let gx = x + gx_off + metrics.xmin;
|
||
let gy = y - metrics.height as i32 - metrics.ymin;
|
||
for row in 0..metrics.height {
|
||
for col in 0..metrics.width {
|
||
let alpha = bitmap[row * metrics.width + col];
|
||
if alpha == 0 { continue; }
|
||
let px_x = gx + col as i32;
|
||
let px_y = gy + row as i32;
|
||
if px_x < 0 || px_y < 0 || px_x >= pw || px_y >= ph { continue; }
|
||
let idx = (px_y as usize * pw as usize + px_x as usize) * 4;
|
||
let data = px.data_mut();
|
||
let a = alpha as u32;
|
||
let ia = 255 - a;
|
||
data[idx] = ((ia * data[idx] as u32 + a * cr) / 255) as u8;
|
||
data[idx + 1] = ((ia * data[idx+1] as u32 + a * cg) / 255) as u8;
|
||
data[idx + 2] = ((ia * data[idx+2] as u32 + a * cb) / 255) as u8;
|
||
data[idx + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_text_width" => {
|
||
// canvas_text_width(text: String, size: Int) -> Int
|
||
let size = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 14.0 };
|
||
let text = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
|
||
let (_, w) = rasterize_text(&text, size);
|
||
stack.push(Value::Int(w as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_text_height" => {
|
||
// canvas_text_height(size: Int) -> Int
|
||
let size = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 14.0 };
|
||
stack.push(Value::Int((size * 1.2) as i64));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_clip" => {
|
||
// canvas_clip(x, y, w, h: Int) -> Void
|
||
let h = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 0 };
|
||
let w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 0 };
|
||
let y = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let x = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
CANVAS.with(|cv| cv.borrow_mut().clips.push((x, y, w, h)));
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_unclip" => {
|
||
CANVAS.with(|cv| { cv.borrow_mut().clips.pop(); });
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_size" => {
|
||
// canvas_size() -> [Int] — [width, height]
|
||
let (w, h) = CANVAS.with(|cv| {
|
||
let cv = cv.borrow();
|
||
(cv.width as i64, cv.height as i64)
|
||
});
|
||
stack.push(Value::List(vec![Value::Int(w), Value::Int(h)]));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_mouse_pos" => {
|
||
// canvas_mouse_pos() -> [Int] — [x, y]
|
||
let (x, y) = CANVAS.with(|cv| {
|
||
let cv = cv.borrow();
|
||
(cv.mouse_x as i64, cv.mouse_y as i64)
|
||
});
|
||
stack.push(Value::List(vec![Value::Int(x), Value::Int(y)]));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_events" => {
|
||
// canvas_events() -> String — JSON array of event objects
|
||
let evts = CANVAS.with(|cv| cv.borrow().events.clone());
|
||
let result = if evts.is_empty() {
|
||
"[]".to_string()
|
||
} else {
|
||
format!("[{}]", evts.join(","))
|
||
};
|
||
stack.push(Value::Str(result));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_swap" => {
|
||
// canvas_swap() -> Void — no-op; canvas_run_loop handles presentation
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
// ── Frame-persistent key-value state ────────────────────────────────
|
||
// state_set / state_get use GLOBAL_STATE so values survive between frames
|
||
// when canvas_run_loop calls the draw function repeatedly.
|
||
|
||
"state_set" => {
|
||
// state_set(key: String, val: String) -> Void
|
||
let val = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
|
||
let key = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
|
||
GLOBAL_STATE.with(|gs| gs.borrow_mut().insert(key, val));
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"state_get" => {
|
||
// state_get(key: String) -> String
|
||
let key = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
|
||
let val = GLOBAL_STATE.with(|gs| gs.borrow().get(&key).cloned().unwrap_or_default());
|
||
stack.push(Value::Str(val));
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
"canvas_run_loop" => {
|
||
// canvas_run_loop(draw_fn: String) -> Void (never returns)
|
||
let draw_fn = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "draw".into() };
|
||
|
||
let arc_instructions = SERVE_INSTRUCTIONS.with(|si| si.borrow().clone())
|
||
.expect("canvas_run_loop: no instructions stored (call canvas_open first and ensure interpreter stored them)");
|
||
let arc_fn_table = SERVE_FN_TABLE.with(|sf| sf.borrow().clone())
|
||
.expect("canvas_run_loop: no fn_table stored");
|
||
|
||
let (title, width, height) = CANVAS.with(|cv| {
|
||
let cv = cv.borrow();
|
||
(cv.title.clone(), cv.width, cv.height)
|
||
});
|
||
|
||
use winit::event::{Event, WindowEvent, MouseButton, ElementState};
|
||
use winit::event_loop::{ControlFlow, EventLoop};
|
||
use winit::keyboard::{Key, NamedKey};
|
||
use winit::window::WindowBuilder;
|
||
|
||
let event_loop = EventLoop::new().expect("failed to create event loop");
|
||
let window = WindowBuilder::new()
|
||
.with_title(&title)
|
||
.with_inner_size(winit::dpi::LogicalSize::new(width, height))
|
||
.build(&event_loop)
|
||
.expect("failed to create window");
|
||
|
||
let context = unsafe { softbuffer::Context::new(&window) }.expect("softbuffer context");
|
||
let mut surface = unsafe { softbuffer::Surface::new(&context, &window) }.expect("softbuffer surface");
|
||
|
||
let mut frame_events: Vec<String> = Vec::new();
|
||
|
||
let _ = event_loop.run(move |event, elwt| {
|
||
elwt.set_control_flow(ControlFlow::Poll);
|
||
|
||
match event {
|
||
Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => {
|
||
std::process::exit(0);
|
||
}
|
||
Event::WindowEvent { event: WindowEvent::Resized(physical), .. } => {
|
||
let nw = physical.width.max(1);
|
||
let nh = physical.height.max(1);
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
cv.width = nw;
|
||
cv.height = nh;
|
||
cv.pixmap = tiny_skia::Pixmap::new(nw, nh);
|
||
});
|
||
frame_events.push(format!(r#"{{"type":"resize","w":{},"h":{}}}"#, nw, nh));
|
||
}
|
||
Event::WindowEvent { event: WindowEvent::CursorMoved { position, .. }, .. } => {
|
||
let mx = position.x as i32;
|
||
let my = position.y as i32;
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
cv.mouse_x = mx;
|
||
cv.mouse_y = my;
|
||
});
|
||
frame_events.push(format!(r#"{{"type":"mouse_move","x":{},"y":{}}}"#, mx, my));
|
||
}
|
||
Event::WindowEvent { event: WindowEvent::MouseInput { state, button, .. }, .. } => {
|
||
let btn_str = match button { MouseButton::Left => "left", MouseButton::Right => "right", _ => "other" };
|
||
let ev_type = match state { ElementState::Pressed => "mouse_down", ElementState::Released => "mouse_up" };
|
||
let (mx, my) = CANVAS.with(|cv| { let cv = cv.borrow(); (cv.mouse_x, cv.mouse_y) });
|
||
frame_events.push(format!(r#"{{"type":"{}","x":{},"y":{},"button":"{}"}}"#, ev_type, mx, my, btn_str));
|
||
if state == ElementState::Released && button == MouseButton::Left {
|
||
frame_events.push(format!(r#"{{"type":"mouse_click","x":{},"y":{},"button":"left"}}"#, mx, my));
|
||
}
|
||
}
|
||
Event::WindowEvent { event: WindowEvent::MouseWheel { delta, .. }, .. } => {
|
||
let (dx, dy) = match delta {
|
||
winit::event::MouseScrollDelta::LineDelta(x, y) => (x as i32, y as i32),
|
||
winit::event::MouseScrollDelta::PixelDelta(p) => (p.x as i32, p.y as i32),
|
||
};
|
||
frame_events.push(format!(r#"{{"type":"scroll","dx":{},"dy":{}}}"#, dx, dy));
|
||
}
|
||
Event::WindowEvent { event: WindowEvent::KeyboardInput { event: ref kev, .. }, .. } => {
|
||
// Emit char events for printable keys
|
||
if kev.state == ElementState::Pressed {
|
||
if let Key::Character(ref s) = kev.logical_key {
|
||
let ch_str = s.as_str();
|
||
if !ch_str.chars().all(|c| c.is_control()) {
|
||
let escaped = ch_str.replace('"', "\\\"");
|
||
frame_events.push(format!(r#"{{"type":"char","char":"{}"}}"#, escaped));
|
||
}
|
||
}
|
||
}
|
||
// Emit key_down events for named keys
|
||
if kev.state == ElementState::Pressed {
|
||
let key_str = match &kev.logical_key {
|
||
Key::Named(NamedKey::Enter) => "Return",
|
||
Key::Named(NamedKey::Escape) => "Escape",
|
||
Key::Named(NamedKey::Backspace) => "Backspace",
|
||
Key::Named(NamedKey::Delete) => "Delete",
|
||
Key::Named(NamedKey::ArrowLeft) => "ArrowLeft",
|
||
Key::Named(NamedKey::ArrowRight) => "ArrowRight",
|
||
Key::Named(NamedKey::ArrowUp) => "ArrowUp",
|
||
Key::Named(NamedKey::ArrowDown) => "ArrowDown",
|
||
Key::Named(NamedKey::Tab) => "Tab",
|
||
Key::Named(NamedKey::Home) => "Home",
|
||
Key::Named(NamedKey::End) => "End",
|
||
_ => "",
|
||
};
|
||
if !key_str.is_empty() {
|
||
frame_events.push(format!(r#"{{"type":"key_down","key":"{}"}}"#, key_str));
|
||
}
|
||
}
|
||
}
|
||
Event::AboutToWait => {
|
||
// Set this frame's events, then call the Engram draw function
|
||
CANVAS.with(|cv| {
|
||
cv.borrow_mut().events = std::mem::take(&mut frame_events);
|
||
});
|
||
|
||
if let Some(&entry) = arc_fn_table.get(draw_fn.as_str()) {
|
||
run_sub_interpreter(&arc_instructions, &arc_fn_table, entry);
|
||
}
|
||
|
||
// Present the pixmap to the screen
|
||
let (w, h) = CANVAS.with(|cv| { let cv = cv.borrow(); (cv.width, cv.height) });
|
||
let nz_w = std::num::NonZeroU32::new(w.max(1)).unwrap();
|
||
let nz_h = std::num::NonZeroU32::new(h.max(1)).unwrap();
|
||
if surface.resize(nz_w, nz_h).is_ok() {
|
||
if let Ok(mut buf) = surface.buffer_mut() {
|
||
CANVAS.with(|cv| {
|
||
let cv = cv.borrow();
|
||
if let Some(px) = &cv.pixmap {
|
||
let pixels = px.data(); // RGBA bytes
|
||
for (i, chunk) in pixels.chunks_exact(4).enumerate() {
|
||
// softbuffer expects 0x00RRGGBB in native u32
|
||
buf[i] = ((chunk[0] as u32) << 16)
|
||
| ((chunk[1] as u32) << 8)
|
||
| (chunk[2] as u32);
|
||
}
|
||
}
|
||
});
|
||
let _ = buf.present();
|
||
}
|
||
}
|
||
|
||
window.request_redraw();
|
||
}
|
||
_ => {}
|
||
}
|
||
});
|
||
// event_loop.run never returns normally (exits via std::process::exit)
|
||
unreachable!()
|
||
}
|
||
|
||
"canvas_image" => {
|
||
// canvas_image(path: String, x: Int, y: Int, w: Int, h: Int) -> Void
|
||
// Draws a PNG image scaled to w×h at (x, y) with alpha blending.
|
||
let draw_h = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let draw_w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let dy = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let dx = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
|
||
let path = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => { stack.push(Value::Nil); return BuiltinResult::Handled; } };
|
||
|
||
if draw_w <= 0 || draw_h <= 0 { stack.push(Value::Nil); return BuiltinResult::Handled; }
|
||
|
||
use image::GenericImageView;
|
||
if let Ok(img) = image::open(&path) {
|
||
let img = img.resize_exact(draw_w as u32, draw_h as u32, image::imageops::FilterType::Lanczos3);
|
||
let rgba = img.to_rgba8();
|
||
CANVAS.with(|cv| {
|
||
let mut cv = cv.borrow_mut();
|
||
if let Some(px) = cv.pixmap.as_mut() {
|
||
let pw = px.width() as i32;
|
||
let ph = px.height() as i32;
|
||
let data = px.data_mut();
|
||
for row in 0..draw_h {
|
||
for col in 0..draw_w {
|
||
let ix = dx + col;
|
||
let iy = dy + row;
|
||
if ix < 0 || iy < 0 || ix >= pw || iy >= ph { continue; }
|
||
let p = rgba.get_pixel(col as u32, row as u32);
|
||
let sa = p[3] as u32;
|
||
if sa == 0 { continue; }
|
||
let ia = 255 - sa;
|
||
let idx = (iy as usize * pw as usize + ix as usize) * 4;
|
||
data[idx] = ((ia * data[idx] as u32 + sa * p[0] as u32) / 255) as u8;
|
||
data[idx + 1] = ((ia * data[idx+1] as u32 + sa * p[1] as u32) / 255) as u8;
|
||
data[idx + 2] = ((ia * data[idx+2] as u32 + sa * p[2] as u32) / 255) as u8;
|
||
data[idx + 3] = ((ia * data[idx+3] as u32 + sa * sa) / 255) as u8;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
stack.push(Value::Nil);
|
||
BuiltinResult::Handled
|
||
}
|
||
|
||
_ => BuiltinResult::NotBuiltin,
|
||
}
|
||
}
|
||
|
||
/// Return terminal dimensions as (cols, rows).
|
||
/// Uses TIOCGWINSZ ioctl on Unix; falls back to (80, 24).
|
||
fn term_size() -> (u16, u16) {
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::io::AsRawFd;
|
||
#[repr(C)]
|
||
struct WinSize { rows: u16, cols: u16, _xpix: u16, _ypix: u16 }
|
||
let ws = WinSize { rows: 0, cols: 0, _xpix: 0, _ypix: 0 };
|
||
unsafe {
|
||
extern "C" {
|
||
fn ioctl(fd: std::ffi::c_int, request: std::ffi::c_ulong, ...) -> std::ffi::c_int;
|
||
}
|
||
// TIOCGWINSZ: macOS = 0x40087468, Linux = 0x5413
|
||
#[cfg(target_os = "macos")]
|
||
ioctl(std::io::stdout().as_raw_fd(), 0x40087468_u64, &ws);
|
||
#[cfg(not(target_os = "macos"))]
|
||
ioctl(std::io::stdout().as_raw_fd(), 0x5413_u64, &ws);
|
||
if ws.cols > 0 && ws.rows > 0 {
|
||
return (ws.cols, ws.rows);
|
||
}
|
||
}
|
||
}
|
||
(80, 24)
|
||
}
|
||
|
||
/// Format a Unix timestamp (seconds + millis) as an ISO 8601 string.
|
||
/// This avoids pulling in chrono while still producing a readable timestamp.
|
||
fn format_epoch_as_iso(secs: u64, millis: u32) -> String {
|
||
// Days since epoch, accounting for leap years
|
||
let mut days = secs / 86400;
|
||
let time_of_day = secs % 86400;
|
||
let hours = time_of_day / 3600;
|
||
let minutes = (time_of_day % 3600) / 60;
|
||
let seconds = time_of_day % 60;
|
||
|
||
// Gregorian calendar calculation starting from 1970-01-01
|
||
let mut year = 1970u64;
|
||
loop {
|
||
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||
if days < days_in_year {
|
||
break;
|
||
}
|
||
days -= days_in_year;
|
||
year += 1;
|
||
}
|
||
let months = [31u64, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||
let mut month = 1u64;
|
||
for &m in &months {
|
||
if days < m {
|
||
break;
|
||
}
|
||
days -= m;
|
||
month += 1;
|
||
}
|
||
let day = days + 1;
|
||
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z")
|
||
}
|
||
|
||
fn is_leap(year: u64) -> bool {
|
||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||
}
|
||
|
||
/// Compare two runtime values for ordering (used by Lt/Gt/LtEq/GtEq).
|
||
fn cmp_values(a: &el_compiler::Value, b: &el_compiler::Value) -> std::cmp::Ordering {
|
||
use el_compiler::Value;
|
||
match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => x.cmp(y),
|
||
(Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal),
|
||
(Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal),
|
||
(Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)).unwrap_or(std::cmp::Ordering::Equal),
|
||
(Value::Str(x), Value::Str(y)) => x.cmp(y),
|
||
_ => std::cmp::Ordering::Equal,
|
||
}
|
||
}
|
||
|
||
/// Check if a function name is a known built-in (used by run_sub_interpreter).
|
||
fn is_builtin(name: &str) -> bool {
|
||
matches!(name, "print" | "println" | "log" | "print_err" | "__build_list__")
|
||
}
|
||
|
||
/// Interpreter with debugger support — emits DebugEvents as it runs.
|
||
fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut el_compiler::Debugger) {
|
||
use el_compiler::{Bytecode, Value};
|
||
let mut stack: Vec<Value> = Vec::new();
|
||
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||
let mut ip = 0usize;
|
||
let program_args: Vec<String> = std::env::args().skip(2).collect(); // skip 'el' and 'debug'
|
||
|
||
while ip < instructions.len() {
|
||
// Check if we should pause here
|
||
if debugger.should_pause(ip) {
|
||
debugger.on_pause(ip, locals.clone());
|
||
for event in debugger.drain_events() {
|
||
match event {
|
||
el_compiler::DebugEvent::Breakpoint { offset, frame } => {
|
||
println!("[break] offset={offset} fn={} {}:{}:{}", frame.function_name, frame.source_file, frame.line, frame.col);
|
||
}
|
||
el_compiler::DebugEvent::Step { frame, locals: step_locals } => {
|
||
let var_list: Vec<String> = step_locals.iter()
|
||
.map(|(k, v)| format!("{k}={v}"))
|
||
.collect();
|
||
println!("[step] offset={ip} {}:{} vars=[{}]", frame.line, frame.col, var_list.join(", "));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
match &instructions[ip] {
|
||
Bytecode::Push(v) => stack.push(v.clone()),
|
||
Bytecode::Pop => { stack.pop(); }
|
||
Bytecode::Dup => {
|
||
if let Some(top) = stack.last().cloned() { stack.push(top); }
|
||
}
|
||
Bytecode::Add => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(match (a, b) {
|
||
(Value::Int(x), Value::Int(y)) => Value::Int(x + y),
|
||
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
|
||
(Value::Str(x), Value::Str(y)) => Value::Str(x + &y),
|
||
_ => Value::Nil,
|
||
});
|
||
}
|
||
Bytecode::StoreLocal(name) => {
|
||
let v = stack.pop().unwrap_or(Value::Nil);
|
||
locals.insert(name.clone(), v);
|
||
}
|
||
Bytecode::LoadLocal(name) => {
|
||
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||
stack.push(v);
|
||
}
|
||
Bytecode::GetField(field) => {
|
||
let obj = stack.pop().unwrap_or(Value::Nil);
|
||
let result = match &obj {
|
||
Value::Map(pairs) => pairs.iter()
|
||
.find(|(k, _)| k == field)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil),
|
||
Value::Struct { fields, .. } => fields.iter()
|
||
.find(|(n, _)| n == field)
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or(Value::Nil),
|
||
_ => Value::Nil,
|
||
};
|
||
stack.push(result);
|
||
}
|
||
Bytecode::BuildStruct { type_name, fields } => {
|
||
let n = fields.len();
|
||
let mut field_values: Vec<Value> = (0..n).map(|_| stack.pop().unwrap_or(Value::Nil)).collect();
|
||
field_values.reverse();
|
||
let struct_fields: Vec<(String, Value)> = fields.iter().cloned()
|
||
.zip(field_values.into_iter())
|
||
.collect();
|
||
stack.push(Value::Struct {
|
||
type_name: type_name.clone(),
|
||
fields: struct_fields,
|
||
});
|
||
}
|
||
Bytecode::Call { name, arity } => {
|
||
let result = dispatch_builtin(name, *arity, &mut stack, &program_args);
|
||
match result {
|
||
BuiltinResult::Handled | BuiltinResult::NotBuiltin | BuiltinResult::HttpServe => {}
|
||
BuiltinResult::Exit(code) => std::process::exit(code),
|
||
}
|
||
}
|
||
Bytecode::Eq => {
|
||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||
stack.push(Value::Bool(a == b));
|
||
}
|
||
Bytecode::Jump(offset) => {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
Bytecode::JumpIf(offset) => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if matches!(cond, Value::Bool(true)) {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::JumpIfNot(offset) => {
|
||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||
if !matches!(cond, Value::Bool(true)) {
|
||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||
ip = new_ip;
|
||
continue;
|
||
}
|
||
}
|
||
Bytecode::Return | Bytecode::Halt => {
|
||
debugger.pop_frame(stack.last().cloned().unwrap_or(Value::Nil));
|
||
break;
|
||
}
|
||
_ => {}
|
||
}
|
||
ip += 1;
|
||
}
|
||
}
|