use std::collections::HashMap;
use std::io::{self, BufRead, Write};
#[derive(Debug)]
pub struct LspServer {
pub initialized: bool,
pub documents: HashMap<String, String>,
pub root_uri: Option<String>,
pub shutdown_requested: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DiagnosticSeverity {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Diagnostic {
pub line: u32,
pub character: u32,
pub end_line: u32,
pub end_character: u32,
pub severity: DiagnosticSeverity,
pub message: String,
pub source: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CompletionItem {
pub label: String,
pub detail: String,
pub insert_text: String,
pub kind: u32,
}
const TOP_LEVEL_KEYS: &[(&str, &str)] = &[
("machines", "Machine definitions (name, host, transport)"),
("resources", "Resource definitions (package, file, service)"),
("data", "Data sources (file, forjar-state, environment)"),
("tags", "Tag assignments for resources"),
("policy", "Policy configuration (convergence, security)"),
("includes", "Include other forjar YAML files"),
("template", "Template parameters for reusable configs"),
];
const RESOURCE_TYPES: &[(&str, &str)] = &[
("package", "System package (apt, yum, brew, cargo)"),
("file", "File resource (content, template, permissions)"),
("service", "System service (systemd, launchd)"),
("mount", "Filesystem mount point"),
("directory", "Directory with ownership/permissions"),
("command", "Arbitrary command execution"),
("git_repo", "Git repository clone/checkout"),
("cron", "Cron job definition"),
("user", "System user account"),
("group", "System group"),
("gpu_driver", "GPU driver installation (NVIDIA/ROCm)"),
];
const RESOURCE_FIELDS: &[(&str, &str)] = &[
("name", "Resource identifier (unique within config)"),
("type", "Resource type (package, file, service, etc.)"),
(
"ensure",
"Desired state: present|absent|latest|running|stopped",
),
("provider", "Package provider: apt, yum, brew, cargo, pip"),
("content", "File content (inline string)"),
("source", "File source path or URL"),
("mode", "File permissions (octal, e.g., '0644')"),
("owner", "File/directory owner"),
("group", "File/directory group"),
("depends_on", "List of resource dependencies"),
("machine", "Target machine name"),
("tags", "Resource tags for filtering"),
("on_success", "Notification on successful convergence"),
("on_failure", "Notification on failure"),
("on_drift", "Notification when drift detected"),
];
impl Default for LspServer {
fn default() -> Self {
Self::new()
}
}
impl LspServer {
pub fn new() -> Self {
LspServer {
initialized: false,
documents: HashMap::new(),
root_uri: None,
shutdown_requested: false,
}
}
pub fn handle_message(&mut self, msg: &serde_json::Value) -> Option<serde_json::Value> {
let method = msg.get("method")?.as_str()?;
let id = msg.get("id");
match method {
"initialize" => {
self.initialized = true;
if let Some(root) = msg.pointer("/params/rootUri") {
self.root_uri = root.as_str().map(String::from);
}
Some(make_response(id, initialize_result()))
}
"initialized" => None, "shutdown" => {
self.shutdown_requested = true;
Some(make_response(id, serde_json::Value::Null))
}
"exit" => std::process::exit(if self.shutdown_requested { 0 } else { 1 }),
"textDocument/didOpen" => {
self.handle_did_open(msg);
None
}
"textDocument/didChange" => {
self.handle_did_change(msg);
None
}
"textDocument/completion" => {
let items = self.handle_completion(msg);
Some(make_response(
id,
serde_json::to_value(items).unwrap_or_default(),
))
}
"textDocument/hover" => {
let hover = self.handle_hover(msg);
Some(make_response(id, hover))
}
_ => {
if id.is_some() {
Some(make_error_response(id, -32601, "Method not found"))
} else {
None }
}
}
}
fn handle_did_open(&mut self, msg: &serde_json::Value) {
if let (Some(uri), Some(text)) = (
msg.pointer("/params/textDocument/uri")
.and_then(|v| v.as_str()),
msg.pointer("/params/textDocument/text")
.and_then(|v| v.as_str()),
) {
self.documents.insert(uri.to_string(), text.to_string());
}
}
fn handle_did_change(&mut self, msg: &serde_json::Value) {
if let (Some(uri), Some(changes)) = (
msg.pointer("/params/textDocument/uri")
.and_then(|v| v.as_str()),
msg.pointer("/params/contentChanges")
.and_then(|v| v.as_array()),
) {
if let Some(text) = changes
.last()
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
{
self.documents.insert(uri.to_string(), text.to_string());
}
}
}
fn handle_completion(&self, msg: &serde_json::Value) -> Vec<CompletionItem> {
let uri = msg
.pointer("/params/textDocument/uri")
.and_then(|v| v.as_str())
.unwrap_or("");
let line = msg
.pointer("/params/position/line")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let doc = match self.documents.get(uri) {
Some(d) => d,
None => return Vec::new(),
};
let current_line = doc.lines().nth(line).unwrap_or("");
let indent = current_line.len() - current_line.trim_start().len();
completion_items(indent, current_line.trim())
}
fn handle_hover(&self, msg: &serde_json::Value) -> serde_json::Value {
let uri = msg
.pointer("/params/textDocument/uri")
.and_then(|v| v.as_str())
.unwrap_or("");
let line = msg
.pointer("/params/position/line")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let doc = match self.documents.get(uri) {
Some(d) => d,
None => return serde_json::Value::Null,
};
let current_line = doc.lines().nth(line).unwrap_or("");
hover_info(current_line.trim())
}
pub fn validate_document(&self, uri: &str) -> Vec<Diagnostic> {
let doc = match self.documents.get(uri) {
Some(d) => d,
None => return Vec::new(),
};
validate_yaml(doc)
}
}
pub(crate) fn completion_items(indent: usize, line_prefix: &str) -> Vec<CompletionItem> {
let mut items = Vec::new();
if indent == 0 && (line_prefix.is_empty() || !line_prefix.contains(':')) {
for (key, desc) in TOP_LEVEL_KEYS {
items.push(CompletionItem {
label: key.to_string(),
detail: desc.to_string(),
insert_text: format!("{key}:"),
kind: 14, });
}
} else if line_prefix.starts_with("type:") || line_prefix.starts_with("- type:") {
for (rtype, desc) in RESOURCE_TYPES {
items.push(CompletionItem {
label: rtype.to_string(),
detail: desc.to_string(),
insert_text: rtype.to_string(),
kind: 6, });
}
} else if indent >= 2 {
for (field, desc) in RESOURCE_FIELDS {
items.push(CompletionItem {
label: field.to_string(),
detail: desc.to_string(),
insert_text: format!("{field}: "),
kind: 6, });
}
}
items
}
pub(super) fn hover_info(line: &str) -> serde_json::Value {
let key = line
.split(':')
.next()
.unwrap_or("")
.trim()
.trim_start_matches("- ");
let desc = TOP_LEVEL_KEYS
.iter()
.chain(RESOURCE_FIELDS.iter())
.chain(RESOURCE_TYPES.iter())
.find(|(k, _)| *k == key)
.map(|(_, d)| *d);
match desc {
Some(d) => serde_json::json!({
"contents": { "kind": "markdown", "value": format!("**{key}**: {d}") }
}),
None => serde_json::Value::Null,
}
}
pub fn validate_yaml(content: &str) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if let Err(e) = serde_yaml_ng::from_str::<serde_json::Value>(content) {
diags.push(Diagnostic {
line: 0,
character: 0,
end_line: 0,
end_character: 80,
severity: DiagnosticSeverity::Error,
message: format!("YAML parse error: {e}"),
source: "forjar-lsp".to_string(),
});
return diags;
}
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.contains('\t') {
diags.push(make_diag(
i,
line,
DiagnosticSeverity::Warning,
"Tabs should not be used in YAML; use spaces",
));
}
if trimmed.starts_with("ensure:") {
let val = trimmed.trim_start_matches("ensure:").trim();
if !["present", "absent", "latest", "running", "stopped", ""].contains(&val) {
diags.push(make_diag(i, line, DiagnosticSeverity::Warning,
&format!("Unknown ensure value '{val}'; expected present|absent|latest|running|stopped")));
}
}
}
for w in &crate::core::parser::check_unknown_fields(content) {
diags.push(make_diag(0, "", DiagnosticSeverity::Warning, &w.message));
}
if let Ok(config) = crate::core::parser::parse_config(content) {
for e in &crate::core::parser::validate_config(&config) {
diags.push(make_diag(0, "", DiagnosticSeverity::Error, &e.message));
}
}
diags
}
pub(super) fn make_diag(
line_idx: usize,
line: &str,
severity: DiagnosticSeverity,
msg: &str,
) -> Diagnostic {
Diagnostic {
line: line_idx as u32,
character: 0,
end_line: line_idx as u32,
end_character: line.len() as u32,
severity,
message: msg.to_string(),
source: "forjar-lsp".to_string(),
}
}
pub(super) fn initialize_result() -> serde_json::Value {
serde_json::json!({
"capabilities": {
"textDocumentSync": 1,
"completionProvider": { "triggerCharacters": [":", " ", "-"] },
"hoverProvider": true,
"diagnosticProvider": { "interFileDependencies": false, "workspaceDiagnostics": false }
},
"serverInfo": { "name": "forjar-lsp", "version": env!("CARGO_PKG_VERSION") }
})
}
pub(super) fn make_response(
id: Option<&serde_json::Value>,
result: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id.cloned().unwrap_or(serde_json::Value::Null),
"result": result
})
}
pub(super) fn make_error_response(
id: Option<&serde_json::Value>,
code: i32,
msg: &str,
) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id.cloned().unwrap_or(serde_json::Value::Null),
"error": { "code": code, "message": msg }
})
}
pub fn write_message<W: Write>(writer: &mut W, msg: &serde_json::Value) -> io::Result<()> {
let body = serde_json::to_string(msg).map_err(io::Error::other)?;
write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?;
writer.flush()
}
pub fn read_message<R: BufRead>(reader: &mut R) -> io::Result<serde_json::Value> {
let mut header = String::new();
let mut content_length: usize = 0;
loop {
header.clear();
reader.read_line(&mut header)?;
let trimmed = header.trim();
if trimmed.is_empty() {
break;
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
content_length = len_str
.parse()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
}
}
if content_length == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"no content length",
));
}
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body)?;
serde_json::from_slice(&body).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub fn cmd_lsp() -> Result<(), String> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut reader = stdin.lock();
let mut writer = stdout.lock();
let mut server = LspServer::new();
loop {
let msg = read_message(&mut reader).map_err(|e| e.to_string())?;
if let Some(resp) = server.handle_message(&msg) {
write_message(&mut writer, &resp).map_err(|e| e.to_string())?;
}
if let Some(method) = msg.get("method").and_then(|m| m.as_str()) {
if method == "textDocument/didOpen" || method == "textDocument/didChange" {
if let Some(uri) = msg
.pointer("/params/textDocument/uri")
.and_then(|v| v.as_str())
{
let diags = server.validate_document(uri);
let notification = super::lsp_publish::publish_diagnostics(uri, &diags);
write_message(&mut writer, ¬ification).map_err(|e| e.to_string())?;
}
}
}
}
}
#[cfg(test)]
pub(super) use super::lsp_publish::publish_diagnostics;