283 lines
9.1 KiB
Rust
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
|
|
}
|
|
}
|