orkmode/crates/ork-server/src/languages.rs

283 lines
9.1 KiB
Rust

//! Language plugin system for code execution.
//!
//! Languages are defined by configuration files that specify how to invoke
//! interpreters. Users add language support via TOML config files in:
//! - ~/.config/orkmode/languages/
//! - /etc/orkmode/languages/
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{self, Result};
use std::path::{Path, PathBuf};
/// Defines how to execute code in a specific language.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageConfig {
/// Command to run (e.g., "python3", "bash").
pub command: String,
/// Argument template. Use `{code}` for inline code, `{file}` for temp file path.
/// Examples: ["-c", "{code}"] or ["{file}"]
pub args: Vec<String>,
/// How to pass code to the interpreter.
#[serde(default)]
pub mode: ExecMode,
/// File extension for file mode (e.g., ".py", ".sh").
#[serde(default)]
pub extension: Option<String>,
/// Alternative names for this language (e.g., ["python", "python3", "py"]).
#[serde(default)]
pub aliases: Vec<String>,
}
/// How code is passed to the interpreter.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ExecMode {
/// Pass code as command-line argument (e.g., `bash -c "code"`).
#[default]
Inline,
/// Write code to temp file and pass path (e.g., `python script.py`).
File,
/// Pass code via stdin.
Stdin,
}
/// Registry of language configurations.
#[derive(Debug, Default)]
pub struct LanguageRegistry {
languages: HashMap<String, LanguageConfig>,
}
impl LanguageRegistry {
/// Create a registry with built-in language defaults.
pub fn new() -> Self {
let mut reg = Self::default();
reg.register_builtins();
reg
}
/// Register built-in language defaults.
/// These can be overridden by user config files.
fn register_builtins(&mut self) {
// Shell languages
self.register("bash", LanguageConfig {
command: "bash".into(),
args: vec!["-c".into(), "{code}".into()],
mode: ExecMode::Inline,
extension: Some(".sh".into()),
aliases: vec!["sh".into(), "shell".into()],
});
// Python
self.register("python", LanguageConfig {
command: "python3".into(),
args: vec!["-c".into(), "{code}".into()],
mode: ExecMode::Inline,
extension: Some(".py".into()),
aliases: vec!["python3".into(), "py".into()],
});
// Ruby
self.register("ruby", LanguageConfig {
command: "ruby".into(),
args: vec!["-e".into(), "{code}".into()],
mode: ExecMode::Inline,
extension: Some(".rb".into()),
aliases: vec!["rb".into()],
});
// Perl
self.register("perl", LanguageConfig {
command: "perl".into(),
args: vec!["-e".into(), "{code}".into()],
mode: ExecMode::Inline,
extension: Some(".pl".into()),
aliases: vec!["pl".into()],
});
// JavaScript/Node.js
self.register("javascript", LanguageConfig {
command: "node".into(),
args: vec!["-e".into(), "{code}".into()],
mode: ExecMode::Inline,
extension: Some(".js".into()),
aliases: vec!["node".into(), "js".into()],
});
}
/// Register a language configuration.
pub fn register(&mut self, name: &str, config: LanguageConfig) {
// Register under primary name
self.languages.insert(name.to_lowercase(), config.clone());
// Register under aliases
for alias in &config.aliases {
self.languages.insert(alias.to_lowercase(), config.clone());
}
}
/// Look up a language by name or alias.
pub fn get(&self, name: &str) -> Option<&LanguageConfig> {
self.languages.get(&name.to_lowercase())
}
/// List all registered language names.
pub fn list(&self) -> Vec<&str> {
let mut names: Vec<_> = self.languages.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
/// Check if a language is registered.
pub fn supports(&self, name: &str) -> bool {
self.languages.contains_key(&name.to_lowercase())
}
/// Load language definitions from a directory.
pub fn load_from_dir(&mut self, dir: &Path) -> Result<usize> {
if !dir.exists() {
return Ok(0);
}
let mut count = 0;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "toml").unwrap_or(false) {
match self.load_from_file(&path) {
Ok(_) => count += 1,
Err(e) => eprintln!("Warning: failed to load {:?}: {}", path, e),
}
}
}
Ok(count)
}
/// Load a single language definition file.
fn load_from_file(&mut self, path: &Path) -> Result<()> {
let content = fs::read_to_string(path)?;
let config: LanguageConfig = toml::from_str(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
// Use filename (without extension) as language name
let name = path.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid filename"))?;
self.register(name, config);
Ok(())
}
/// Load languages from standard config directories.
/// Returns number of languages loaded.
pub fn load_standard_configs(&mut self) -> Result<usize> {
let mut total = 0;
// System config (loaded first, can be overridden by user)
total += self.load_from_dir(Path::new("/etc/orkmode/languages"))?;
// User config
if let Some(home) = std::env::var_os("HOME") {
let user_dir = PathBuf::from(home).join(".config/orkmode/languages");
total += self.load_from_dir(&user_dir)?;
}
Ok(total)
}
/// Build command and arguments for executing code.
pub fn build_command(&self, lang: &str, code: &str) -> Result<(String, Vec<String>)> {
let config = self.get(lang)
.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported language: {} (no plugin found)", lang),
))?;
let cmd = config.command.clone();
let args: Vec<String> = config.args.iter()
.map(|arg| arg.replace("{code}", code))
.collect();
Ok((cmd, args))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_builtin_languages() {
let reg = LanguageRegistry::new();
// Builtins are registered by default
assert!(reg.get("bash").is_some());
assert!(reg.get("sh").is_some()); // alias
assert!(reg.get("python").is_some());
assert!(reg.get("ruby").is_some());
assert!(reg.get("perl").is_some());
assert!(reg.get("javascript").is_some());
assert!(reg.get("node").is_some()); // alias
// Unknown language still fails
assert!(reg.get("fortran").is_none());
assert!(reg.build_command("fortran", "code").is_err());
}
#[test]
fn test_register_language_override() {
let mut reg = LanguageRegistry::new();
// Override builtin with custom config
reg.register("bash", LanguageConfig {
command: "/custom/bash".into(),
args: vec!["-c".into(), "{code}".into()],
mode: ExecMode::Inline,
extension: Some(".sh".into()),
aliases: vec!["sh".into()],
});
let config = reg.get("bash").unwrap();
assert_eq!(config.command, "/custom/bash"); // overridden
assert!(reg.get("sh").is_some()); // alias
assert!(reg.get("BASH").is_some()); // case insensitive
}
#[test]
fn test_build_command_builtin() {
let reg = LanguageRegistry::new();
// Python is a builtin
let (cmd, args) = reg.build_command("python", "print(1)").unwrap();
assert_eq!(cmd, "python3");
assert_eq!(args, vec!["-c", "print(1)"]);
}
#[test]
fn test_load_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rust.toml");
let mut file = fs::File::create(&path).unwrap();
writeln!(file, r#"
command = "rustc"
args = ["--edition", "2021", "-o", "/tmp/out", "{{file}}"]
mode = "file"
extension = ".rs"
aliases = ["rs"]
"#).unwrap();
let mut reg = LanguageRegistry::new();
reg.load_from_file(&path).unwrap();
let config = reg.get("rust").unwrap();
assert_eq!(config.command, "rustc");
assert!(reg.get("rs").is_some()); // alias
}
}