use crate::types::Cli;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
pub struct CliRunner {
pub commands: Vec<(String, Cli)>,
pub extension: String,
pub lang_name: String,
}
impl CliRunner {
pub fn new(lang_name: &str, extension: &str) -> Self {
Self {
commands: vec![],
extension: extension.to_string(),
lang_name: lang_name.to_string(),
}
}
pub fn register(&mut self, name: &str, cli: Cli) {
self.commands.push((name.to_string(), cli));
}
pub fn run_with_args(
&self,
args: Vec<String>,
run_source: &dyn Fn(&str) -> Result<(), String>,
run_path: &dyn Fn(&str) -> Result<(), String>,
check_path: &dyn Fn(&str) -> Result<(), String>,
build_path: &dyn Fn(&str) -> Result<String, String>,
lsp_analyzer: &dyn Fn(Option<&str>, &str) -> Result<(), String>,
) {
self.run_with_args_ex(
args,
run_source,
run_path,
check_path,
build_path,
None,
lsp_analyzer,
);
}
pub fn run_with_args_ex(
&self,
args: Vec<String>,
run_source: &dyn Fn(&str) -> Result<(), String>,
run_path: &dyn Fn(&str) -> Result<(), String>,
check_path: &dyn Fn(&str) -> Result<(), String>,
build_path: &dyn Fn(&str) -> Result<String, String>,
build_exe_path: Option<&dyn Fn(&str) -> Result<String, String>>,
lsp_analyzer: &dyn Fn(Option<&str>, &str) -> Result<(), String>,
) {
let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("help");
let file = args.get(2).map(|s| s.as_str()).unwrap_or("");
let lsp_mode = args.iter().any(|a| a == "--lsp");
let fail_fast = args.iter().any(|a| a == "--fail-fast");
let build_exe = args.iter().any(|a| a == "--exe" || a == "--native");
match cmd {
"lsp" => {
self.run_lsp_server(lsp_analyzer);
}
"help" => self.print_help(),
"version" => println!("{} language toolkit", self.lang_name),
c => {
let found = self.commands.iter().find(|(name, _)| name == c);
match found {
Some((_, Cli::Run)) => {
if file.is_empty() {
eprintln!("Usage: {} run <file>", self.lang_name);
return;
}
if let Err(e) = run_path(file) {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
Some((_, Cli::Repl)) => self.run_repl(run_source),
Some((_, Cli::Test)) => {
let dir = if file.is_empty() { "tests" } else { file };
if let Err(e) = self.run_tests(dir, run_path, fail_fast) {
eprintln!("Test error: {}", e);
std::process::exit(1);
}
}
Some((_, Cli::Add)) => {
if file.is_empty() {
eprintln!("Usage: {} add <name>", self.lang_name);
return;
}
if let Err(e) = self.add_package(file) {
eprintln!("Add error: {}", e);
std::process::exit(1);
}
}
Some((_, Cli::Check)) => {
if file.is_empty() {
eprintln!("Usage: {} check <file>", self.lang_name);
return;
}
match check_path(file) {
Ok(_) => {
if lsp_mode {
println!("{}", lsp_ok_json(file));
} else {
println!("OK");
}
}
Err(e) => {
if lsp_mode {
println!("{}", lsp_error_json(file, &e));
} else {
eprintln!("Error: {}", e);
}
}
}
}
Some((_, Cli::Init)) => {
if file.is_empty() {
eprintln!("Usage: {} init <project_name>", self.lang_name);
return;
}
let project_name = file;
let extension = &self.extension;
println!("Initializing new language project '{}'...", project_name);
let root_dir = std::path::PathBuf::from(project_name);
let libs_dir = root_dir.join("libs");
let build_dir = root_dir.join("build");
if let Err(e) = std::fs::create_dir_all(&libs_dir) {
eprintln!("Failed to create libs directory: {}", e);
return;
}
if let Err(e) = std::fs::create_dir_all(&build_dir) {
eprintln!("Failed to create build directory: {}", e);
return;
}
let config_content = format!(
r#"name = "{}"
extension = "{}"
lib = "base"
build_dir = "build"
libs_dir = "libs"
"#,
project_name, extension
);
let main_content = r#"print "Hello from langkit!"
"#;
let gitignore_content = "/build\n*.lbc\n";
if let Err(e) = std::fs::write(root_dir.join("langkit.toml"), config_content) {
eprintln!("Failed to write langkit.toml: {}", e);
}
if let Err(e) =
std::fs::write(root_dir.join(format!("main{}", extension)), main_content)
{
eprintln!("Failed to write main file: {}", e);
}
if let Err(e) = std::fs::write(root_dir.join(".gitignore"), gitignore_content)
{
eprintln!("Failed to write .gitignore: {}", e);
}
println!(
"Successfully created project '{}'.\n\nNext steps:\n cd {}\n {} run main{}",
project_name, project_name, self.lang_name, extension
);
}
Some((_, Cli::Format)) => {
if file.is_empty() {
eprintln!("Usage: {} fmt <file>", self.lang_name);
return;
}
match self.format_file(file) {
Ok(_) => println!("Formatted {}", file),
Err(e) => eprintln!("Format error: {}", e),
}
}
Some((_, Cli::Build)) => {
if file.is_empty() {
eprintln!("Usage: {} build <file>", self.lang_name);
return;
}
if build_exe {
if let Some(build_exe_path) = build_exe_path {
match build_exe_path(file) {
Ok(out) => println!("Built {}", out),
Err(e) => eprintln!("Build error: {}", e),
}
} else {
eprintln!("Build error: native build not available");
}
return;
}
match build_path(file) {
Ok(out) => println!("Built {}", out),
Err(e) => eprintln!("Build error: {}", e),
}
}
Some((_, Cli::Update)) => println!("Update not yet implemented"),
Some((_, Cli::Docs)) => println!("Docs not yet implemented"),
Some((_, Cli::Cancel)) => println!("Nothing to cancel"),
Some((_, Cli::Lsp)) => self.run_lsp_server(lsp_analyzer),
Some((_, Cli::Custom(f))) => f(args),
None => eprintln!("Unknown command: '{}'. Run '{} help'", c, self.lang_name),
}
}
}
}
fn run_repl(&self, runner: &dyn Fn(&str) -> Result<(), String>) {
println!("{} REPL - type 'exit' to quit", self.lang_name);
let mut rl = rustyline::DefaultEditor::new().ok();
let history_path = repl_history_path();
if let (Some(ref mut r), Some(ref path)) = (rl.as_mut(), history_path.as_ref()) {
let _ = r.load_history(path);
}
let mut buffer = String::new();
loop {
let prompt = if buffer.is_empty() { "> " } else { ".. " };
let line = if let Some(ref mut r) = rl {
match r.readline(prompt) {
Ok(l) => l,
Err(_) => break,
}
} else {
print!("{}", prompt);
io::stdout().flush().ok();
let mut line = String::new();
if io::stdin().read_line(&mut line).is_err() {
break;
}
line
};
let trimmed = line.trim();
if buffer.is_empty() && (trimmed == "exit" || trimmed == "quit") {
break;
}
let mut is_continuation = false;
if trimmed.ends_with('\\') {
is_continuation = true;
buffer.push_str(line.trim_end().strip_suffix('\\').unwrap_or(trimmed));
buffer.push('\n');
} else {
buffer.push_str(&line);
buffer.push('\n');
let open_braces = buffer.chars().filter(|&c| c == '{').count();
let close_braces = buffer.chars().filter(|&c| c == '}').count();
if open_braces > close_braces {
is_continuation = true;
}
}
if is_continuation {
continue;
}
let code = buffer.trim();
if code.is_empty() {
buffer.clear();
continue;
}
if let Some(ref mut r) = rl {
let _ = r.add_history_entry(code);
}
if let Err(e) = runner(code) {
eprintln!("Error: {}", e);
}
buffer.clear();
}
if let (Some(ref mut r), Some(ref path)) = (rl.as_mut(), history_path.as_ref()) {
let _ = r.save_history(path);
}
}
fn run_lsp_server(&self, analyzer: &dyn Fn(Option<&str>, &str) -> Result<(), String>) {
let stdin = io::stdin();
let mut reader = io::BufReader::new(stdin.lock());
let mut docs: std::collections::HashMap<String, String> = std::collections::HashMap::new();
loop {
let msg = match read_lsp_message(&mut reader) {
Ok(Some(m)) => m,
Ok(None) => break,
Err(_) => break,
};
let parsed: serde_json::Value = match serde_json::from_str(&msg) {
Ok(v) => v,
Err(_) => continue,
};
let method = parsed.get("method").and_then(|m| m.as_str());
if method.is_none() {
continue;
}
let method = method.unwrap();
match method {
"initialize" => {
if let Some(id) = parsed.get("id") {
let result = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"capabilities": {
"textDocumentSync": 1
}
}
});
send_lsp_message(&result.to_string());
}
}
"shutdown" => {
if let Some(id) = parsed.get("id") {
let result = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": null
});
send_lsp_message(&result.to_string());
}
}
"exit" => break,
"textDocument/didOpen" => {
if let Some(params) = parsed.get("params") {
let uri = params
.get("textDocument")
.and_then(|d| d.get("uri"))
.and_then(|u| u.as_str());
let text = params
.get("textDocument")
.and_then(|d| d.get("text"))
.and_then(|t| t.as_str());
if let (Some(uri), Some(text)) = (uri, text) {
docs.insert(uri.to_string(), text.to_string());
publish_diagnostics(uri, text, analyzer);
}
}
}
"textDocument/didChange" => {
if let Some(params) = parsed.get("params") {
let uri = params
.get("textDocument")
.and_then(|d| d.get("uri"))
.and_then(|u| u.as_str());
let text = params
.get("contentChanges")
.and_then(|c| c.get(0))
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str());
if let (Some(uri), Some(text)) = (uri, text) {
docs.insert(uri.to_string(), text.to_string());
publish_diagnostics(uri, text, analyzer);
}
}
}
"textDocument/didSave" => {
if let Some(params) = parsed.get("params") {
let uri = params
.get("textDocument")
.and_then(|d| d.get("uri"))
.and_then(|u| u.as_str());
if let Some(uri) = uri {
if let Some(text) = docs.get(uri) {
publish_diagnostics(uri, text, analyzer);
}
}
}
}
_ => {}
}
}
}
fn print_help(&self) {
println!("Usage: {} <command> [file]\n\nCommands:", self.lang_name);
for (name, _) in &self.commands {
println!(" {}", name);
}
println!(" help\n version\n lsp");
println!("\nOptions:\n --lsp Output LSP-style JSON diagnostics (check only)\n --fail-fast Stop tests on first failure\n --exe Build a native executable (build only)");
}
}
impl CliRunner {
fn run_tests(
&self,
dir: &str,
run_path: &dyn Fn(&str) -> Result<(), String>,
fail_fast: bool,
) -> Result<(), String> {
let root = std::path::Path::new(dir);
if !root.exists() {
return Err(format!("Tests directory '{}' not found", dir));
}
let mut total = 0usize;
let mut failed = 0usize;
for entry in std::fs::read_dir(root).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
let want = self.extension.trim_start_matches('.');
if ext != want {
continue;
}
} else {
continue;
}
total += 1;
let p = path.to_string_lossy().to_string();
if let Err(e) = run_path(&p) {
failed += 1;
eprintln!("FAIL {}: {}", p, e);
if fail_fast {
return Err(format!("Fail-fast: {} of {} tests failed", failed, total));
}
}
}
}
if failed > 0 {
return Err(format!("{} of {} tests failed", failed, total));
}
println!("OK ({} tests)", total);
Ok(())
}
fn add_package(&self, name: &str) -> Result<(), String> {
let path = std::path::Path::new("langkit.toml");
let mut content = if path.exists() {
std::fs::read_to_string(path).map_err(|e| e.to_string())?
} else {
String::new()
};
let cfg = parse_config_kv(&content);
let ext = cfg
.get("extension")
.cloned()
.unwrap_or_else(|| self.extension.clone());
let libs_mode = cfg
.get("libs_mode")
.map(|v| v.to_lowercase())
.unwrap_or_else(|| "local".to_string());
let libs_dir = cfg
.get("libs_dir")
.cloned()
.unwrap_or_else(|| "libs".to_string());
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let mut stdlib_idx = None;
for (i, line) in lines.iter().enumerate() {
if line.trim_start().starts_with("stdlib") {
stdlib_idx = Some(i);
break;
}
}
if let Some(i) = stdlib_idx {
let line = lines[i].clone();
if !line.contains(name) {
if let Some(pos) = line.find('=') {
let mut val = line[pos + 1..].trim().to_string();
if !val.starts_with('[') {
val = format!("[{}]", val);
}
if val.ends_with(']') {
val.pop();
}
if !val.ends_with('[') {
val.push_str(", ");
}
val.push_str(&format!("\"{}\"]", name));
lines[i] = format!("stdlib = {}", val);
}
}
} else {
lines.push(format!("stdlib = [\"{}\"]", name));
}
content = lines.join("\n");
std::fs::write(path, content).map_err(|e| e.to_string())?;
if libs_mode != "cargo" {
let libs_path = std::path::Path::new(&libs_dir);
let _ = std::fs::create_dir_all(libs_path);
let stub_path = libs_path.join(format!("{}{}", name, normalize_extension(&ext)));
if !stub_path.exists() {
let stub = format!("// {}\n", name);
let _ = std::fs::write(&stub_path, stub);
}
}
println!("Added '{}' to langkit.toml", name);
Ok(())
}
fn format_file(&self, path: &str) -> Result<(), String> {
let src = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let formatted = format_source_basic(&src);
std::fs::write(path, formatted).map_err(|e| e.to_string())?;
Ok(())
}
}
fn format_source_basic(input: &str) -> String {
let mut out = String::new();
let mut indent = 0usize;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
out.push('\n');
continue;
}
let mut indent_for_line = indent;
if trimmed.starts_with('}') && indent_for_line > 0 {
indent_for_line -= 1;
}
out.push_str(&" ".repeat(indent_for_line));
out.push_str(trimmed);
out.push('\n');
let open = trimmed.chars().filter(|&c| c == '{').count();
let close = trimmed.chars().filter(|&c| c == '}').count();
let close_before = if trimmed.starts_with('}') { 1 } else { 0 };
let close_after = close.saturating_sub(close_before);
if open > close_after {
indent += open - close_after;
} else {
indent = indent.saturating_sub(close_after - open);
}
}
out
}
fn parse_config_kv(input: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for raw in input.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
continue;
}
let (key, val) = if let Some(idx) = line.find('=') {
(&line[..idx], &line[idx + 1..])
} else if let Some(idx) = line.find(':') {
(&line[..idx], &line[idx + 1..])
} else {
continue;
};
let key = key.trim().to_lowercase();
let mut val = val.trim().to_string();
if (val.starts_with('"') && val.ends_with('"'))
|| (val.starts_with('\'') && val.ends_with('\''))
{
val = val[1..val.len() - 1].to_string();
}
map.insert(key, val);
}
map
}
fn normalize_extension(ext: &str) -> String {
let trimmed = ext.trim();
if trimmed.starts_with('.') {
trimmed.to_string()
} else {
format!(".{}", trimmed)
}
}
fn lsp_ok_json(path: &str) -> String {
let uri = path_to_uri(path);
format!(
"{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",\"params\":{{\"uri\":\"{}\",\"diagnostics\":[]}}}}",
uri
)
}
fn lsp_error_json(path: &str, err: &str) -> String {
let uri = path_to_uri(path);
let (line, col, msg) = parse_line_col(err);
let msg = json_escape(&msg);
format!(
"{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/publishDiagnostics\",\"params\":{{\"uri\":\"{}\",\"diagnostics\":[{{\"range\":{{\"start\":{{\"line\":{},\"character\":{}}},\"end\":{{\"line\":{},\"character\":{}}}}},\"severity\":1,\"source\":\"langkit\",\"message\":\"{}\"}}]}}}}",
uri,
line,
col,
line,
col.saturating_add(1),
msg
)
}
fn parse_line_col(err: &str) -> (usize, usize, String) {
let mut line = 0usize;
let mut col = 0usize;
let mut msg = err.to_string();
if let Some(idx) = err.find("Line ") {
let rest = &err[idx + 5..];
let mut parts = rest.splitn(2, ':');
if let Some(line_str) = parts.next() {
if let Ok(l) = line_str.trim().parse::<usize>() {
line = l.saturating_sub(1);
}
}
if let Some(after_line) = parts.next() {
let mut col_parts = after_line.splitn(2, ':');
if let Some(col_str) = col_parts.next() {
if let Ok(c) = col_str.trim().parse::<usize>() {
col = c.saturating_sub(1);
}
}
if let Some(rest_msg) = col_parts.next() {
msg = rest_msg.trim().to_string();
}
}
}
(line, col, msg)
}
fn json_escape(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
fn path_to_uri(path: &str) -> String {
let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.into());
let mut s = abs.to_string_lossy().replace('\\', "/");
if !s.starts_with('/') {
s = format!("/{}", s);
}
format!("file://{}", s)
}
fn read_lsp_message(reader: &mut dyn BufRead) -> io::Result<Option<String>> {
let mut content_length = None;
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
return Ok(None);
}
let trimmed = line.trim();
if trimmed.is_empty() {
break;
}
if let Some(rest) = trimmed.strip_prefix("Content-Length:") {
content_length = rest.trim().parse::<usize>().ok();
}
}
let len = match content_length {
Some(l) => l,
None => return Ok(None),
};
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf)?;
Ok(Some(String::from_utf8_lossy(&buf).to_string()))
}
fn send_lsp_message(payload: &str) {
let mut out = io::stdout();
let header = format!("Content-Length: {}\r\n\r\n", payload.as_bytes().len());
let _ = out.write_all(header.as_bytes());
let _ = out.write_all(payload.as_bytes());
let _ = out.flush();
}
fn publish_diagnostics(
uri: &str,
text: &str,
analyzer: &dyn Fn(Option<&str>, &str) -> Result<(), String>,
) {
let path = uri_to_path(uri);
let result = analyzer(path.as_deref(), text);
let diag = match result {
Ok(()) => serde_json::json!([]),
Err(e) => {
let (line, col, msg) = parse_line_col(&e);
serde_json::json!([{
"range": {
"start": { "line": line, "character": col },
"end": { "line": line, "character": col + 1 }
},
"severity": 1,
"source": "langkit",
"message": msg
}])
}
};
let payload = serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": uri,
"diagnostics": diag
}
});
send_lsp_message(&payload.to_string());
}
fn uri_to_path(uri: &str) -> Option<String> {
if let Some(path) = uri.strip_prefix("file://") {
let mut decoded = path.replace("%20", " ");
if decoded.len() > 2 && decoded.as_bytes()[0] == b'/' && decoded.as_bytes()[2] == b':' {
decoded = decoded[1..].to_string();
}
return Some(decoded);
}
None
}
fn repl_history_path() -> Option<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Some(PathBuf::from(home).join(".langkit_history"));
}
if let Ok(home) = std::env::var("USERPROFILE") {
return Some(PathBuf::from(home).join(".langkit_history"));
}
None
}