Archived
07cfde2402
Enables full TUI rendering from Engram programs — cursor positioning, screen clearing, inline (no-newline) printing, terminal size detection via TIOCGWINSZ, and SSE streaming with inline token output.
4370 lines
176 KiB
Rust
4370 lines
176 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);
|
|
}
|
|
|
|
// ── 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();
|
|
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();
|
|
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())
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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();
|
|
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)
|
|
};
|
|
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="font-size:1.1rem;line-height:1.6">It'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>
|
|
<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, 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
|
|
}
|
|
|
|
_ => 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;
|
|
}
|
|
}
|