langkit 1.0.0-beta.1

A builder library for creating programming languages in Rust
Documentation
use crate::types::Cli;
use std::io::{self, BufRead, Write};

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>,
    ) {
        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");

        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::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::New)) => {
                        if file.is_empty() {
                            eprintln!("Usage: {} new <name>", self.lang_name);
                            return;
                        }
                        std::fs::create_dir_all(file).ok();
                        std::fs::write(
                            format!("{}/main{}", file, self.extension),
                            format!("// {} project\n", file),
                        )
                        .ok();
                        let _ = std::fs::write(
                            format!("{}/langkit.toml", file),
                            format!(
                                "name = \"{}\"\nextension = \"{}\"\nbuild_dir = \"build\"\nlibs_dir = \"libs\"\n",
                                self.lang_name, self.extension
                            ),
                        );
                        println!("Created project '{}'", file);
                    }
                    Some((_, Cli::Format)) => println!("Formatter not yet implemented"),
                    Some((_, Cli::Build)) => {
                        if file.is_empty() {
                            eprintln!("Usage: {} build <file>", self.lang_name);
                            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);
        loop {
            print!("> ");
            io::stdout().flush().ok();
            let mut line = String::new();
            if io::stdin().read_line(&mut line).is_err() {
                break;
            }
            let line = line.trim();
            if line == "exit" || line == "quit" {
                break;
            }
            if line.is_empty() {
                continue;
            }
            if let Err(e) = runner(line) {
                eprintln!("Error: {}", e);
            }
        }
    }

    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)");
    }
}

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
}