use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService};
use crate::actions;
use crate::circleci;
use crate::gitlab;
use crate::model::{Diagnostic as EnvDiagnostic, Severity as EnvSeverity};
use crate::AnalyzeOptions;
#[derive(Debug, Clone)]
struct Symbol {
name: String,
line: usize,
state: String,
value: Option<String>,
winner_label: String,
winner_path: Option<std::path::PathBuf>,
winner_line: Option<usize>,
}
#[derive(Debug, Clone)]
struct LspDiag {
line: Option<usize>,
severity: DiagnosticSeverity,
code: String,
message: String,
}
type SymbolTuple = (
String,
usize,
String,
Option<String>,
String,
Option<std::path::PathBuf>,
Option<usize>,
);
#[derive(Debug, Clone, Default)]
struct LspAnalysis {
symbols: Vec<Symbol>,
diagnostics: Vec<LspDiag>,
}
pub struct Backend {
client: Client,
cache: Mutex<HashMap<Url, LspAnalysis>>,
}
fn severity(severity: EnvSeverity) -> DiagnosticSeverity {
match severity {
EnvSeverity::Info => DiagnosticSeverity::INFORMATION,
EnvSeverity::Warning => DiagnosticSeverity::WARNING,
EnvSeverity::Error => DiagnosticSeverity::ERROR,
}
}
fn from_env_diag(diagnostic: &EnvDiagnostic) -> LspDiag {
LspDiag {
line: None,
severity: severity(diagnostic.severity),
code: diagnostic.code.clone(),
message: diagnostic.message.clone(),
}
}
fn analyze_file(
path: &Path,
content: Option<&str>,
) -> std::result::Result<LspAnalysis, Box<dyn std::error::Error>> {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let parent = path
.parent()
.map(|dir| dir.display().to_string())
.unwrap_or_default();
let workflow_dir = parent.ends_with(".github/workflows");
let is_compose = matches!(
file_name.as_str(),
"compose.yaml" | "compose.yml" | "docker-compose.yaml" | "docker-compose.yml"
);
let mut analysis = LspAnalysis::default();
let mut collect = |symbols: Vec<SymbolTuple>, diagnostics: Vec<LspDiag>| {
analysis.symbols = symbols
.into_iter()
.map(
|(name, line, state, value, winner_label, winner_path, winner_line)| Symbol {
name,
line,
state,
value,
winner_label,
winner_path,
winner_line,
},
)
.collect();
analysis.diagnostics = diagnostics;
};
if is_compose {
let report = crate::analyze_with_content(
&AnalyzeOptions {
compose_file: path.to_path_buf(),
docker_check: false,
..AnalyzeOptions::default()
},
content,
)?;
let mut symbols = Vec::new();
let mut diagnostics = Vec::new();
for diagnostic in &report.diagnostics {
diagnostics.push(from_env_diag(diagnostic));
}
for service in &report.services {
for variable in &service.variables {
let line = variable
.winner
.as_ref()
.and_then(|winner| winner.line)
.unwrap_or(1);
let winner_label = variable
.winner
.as_ref()
.map(|winner| format!("{:?} ({})", winner.kind, winner.label()))
.unwrap_or_else(|| "unknown source".to_string());
let (winner_path, winner_line) = variable
.winner
.as_ref()
.map(|winner| (winner.path.clone(), winner.line))
.unwrap_or((None, None));
symbols.push((
variable.variable.clone(),
line,
match variable.state {
crate::model::VariableState::Present => "set".to_string(),
crate::model::VariableState::Absent => "absent".to_string(),
},
variable.value.clone(),
winner_label,
winner_path,
winner_line,
));
for diagnostic in &variable.diagnostics {
let mut diag = from_env_diag(diagnostic);
diag.line = Some(line);
diagnostics.push(diag);
}
}
}
collect(symbols, diagnostics);
} else if workflow_dir && (file_name.ends_with(".yml") || file_name.ends_with(".yaml")) {
let report = actions::analyze_workflow_with_content(path, None, content)?;
let mut symbols = Vec::new();
let mut diagnostics = Vec::new();
for diagnostic in &report.diagnostics {
diagnostics.push(from_env_diag(diagnostic));
}
for job in &report.jobs {
for step in &job.steps {
for diagnostic in &step.diagnostics {
let line = step
.variables
.first()
.and_then(|variable| {
variable.winner.as_ref().and_then(|winner| winner.line)
})
.unwrap_or(1);
let mut diag = from_env_diag(diagnostic);
diag.line = Some(line);
diagnostics.push(diag);
}
}
for variable in job
.variables
.iter()
.chain(job.steps.iter().flat_map(|step| step.variables.iter()))
{
let line = variable
.winner
.as_ref()
.and_then(|winner| winner.line)
.unwrap_or(1);
let winner_label = variable
.winner
.as_ref()
.map(|winner| format!("{:?} ({})", winner.kind, winner.label()))
.unwrap_or_else(|| "unknown source".to_string());
let (winner_path, winner_line) = variable
.winner
.as_ref()
.map(|winner| (winner.path.clone(), winner.line))
.unwrap_or((None, None));
symbols.push((
variable.variable.clone(),
line,
match variable.state {
crate::model::VariableState::Present => "set".to_string(),
crate::model::VariableState::Absent => "absent".to_string(),
},
variable.value.clone(),
winner_label,
winner_path,
winner_line,
));
for diagnostic in &variable.diagnostics {
let mut diag = from_env_diag(diagnostic);
diag.line = Some(line);
diagnostics.push(diag);
}
}
}
collect(symbols, diagnostics);
} else if file_name == ".gitlab-ci.yml" {
let report = gitlab::analyze_gitlab_with_content(path, content)?;
let mut symbols = Vec::new();
let mut diagnostics = Vec::new();
for diagnostic in &report.diagnostics {
diagnostics.push(from_env_diag(diagnostic));
}
let mut all: Vec<&gitlab::GitlabVariable> = report
.global_variables
.iter()
.chain(report.jobs.iter().flat_map(|job| job.variables.iter()))
.collect();
all.sort_by_key(|variable| {
variable
.winner
.as_ref()
.and_then(|winner| winner.line)
.unwrap_or(1)
});
all.dedup_by_key(|variable| variable.variable.clone());
for variable in all {
let line = variable
.winner
.as_ref()
.and_then(|winner| winner.line)
.unwrap_or(1);
let winner_label = variable
.winner
.as_ref()
.map(|winner| format!("{:?} ({})", winner.kind, winner.label()))
.unwrap_or_else(|| "unknown source".to_string());
let (winner_path, winner_line) = variable
.winner
.as_ref()
.map(|winner| (winner.path.clone(), winner.line))
.unwrap_or((None, None));
symbols.push((
variable.variable.clone(),
line,
match variable.state {
crate::model::VariableState::Present => "set".to_string(),
crate::model::VariableState::Absent => "absent".to_string(),
},
variable.value.clone(),
winner_label,
winner_path,
winner_line,
));
for diagnostic in &variable.diagnostics {
let mut diag = from_env_diag(diagnostic);
diag.line = Some(line);
diagnostics.push(diag);
}
}
collect(symbols, diagnostics);
} else if file_name == "config.yml" && parent.ends_with(".circleci") {
let report = circleci::analyze_circleci_with_content(path, content)?;
let mut symbols = Vec::new();
let mut diagnostics = Vec::new();
for diagnostic in &report.diagnostics {
diagnostics.push(from_env_diag(diagnostic));
}
for job in &report.jobs {
for variable in &job.variables {
let line = variable
.winner
.as_ref()
.and_then(|winner| winner.line)
.unwrap_or(1);
let winner_label = variable
.winner
.as_ref()
.map(|winner| format!("{:?} ({})", winner.kind, winner.label()))
.unwrap_or_else(|| "unknown source".to_string());
let (winner_path, winner_line) = variable
.winner
.as_ref()
.map(|winner| (winner.path.clone(), winner.line))
.unwrap_or((None, None));
symbols.push((
variable.variable.clone(),
line,
match variable.state {
crate::model::VariableState::Present => "set".to_string(),
crate::model::VariableState::Absent => "absent".to_string(),
},
variable.value.clone(),
winner_label,
winner_path,
winner_line,
));
for diagnostic in &variable.diagnostics {
let mut diag = from_env_diag(diagnostic);
diag.line = Some(line);
diagnostics.push(diag);
}
}
}
collect(symbols, diagnostics);
} else {
return Ok(LspAnalysis::default());
}
Ok(analysis)
}
fn symbol_at_line(analysis: &LspAnalysis, line: u32) -> Option<&Symbol> {
analysis
.symbols
.iter()
.find(|symbol| symbol.line == line as usize + 1)
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
..Default::default()
},
..Default::default()
})
}
async fn initialized(&self, _: InitializedParams) {
let _ = self
.client
.log_message(MessageType::INFO, "envorigin LSP ready")
.await;
}
async fn shutdown(&self) -> Result<()> {
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let content = params.text_document.text.clone();
self.analyze_and_publish(¶ms.text_document.uri, Some(&content))
.await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let content = params
.content_changes
.last()
.map(|change| change.text.clone());
self.analyze_and_publish(¶ms.text_document.uri, content.as_deref())
.await;
}
async fn did_save(&self, params: DidSaveTextDocumentParams) {
self.analyze_and_publish(¶ms.text_document.uri, None)
.await;
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let cache = self.cache.lock().unwrap();
let Some(analysis) = cache.get(&uri) else {
return Ok(None);
};
let Some(symbol) = symbol_at_line(analysis, position.line) else {
return Ok(None);
};
let value = symbol
.value
.as_deref()
.map(|value| format!(" = `{value}`"))
.unwrap_or_default();
let markdown = format!(
"`{}` · {}{}\n\n← {}",
symbol.name, symbol.state, value, symbol.winner_label
);
Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: markdown,
}),
range: Some(Range {
start: Position {
line: position.line,
character: 0,
},
end: Position {
line: position.line,
character: 0,
},
}),
}))
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> Result<Option<GotoDefinitionResponse>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let cache = self.cache.lock().unwrap();
let Some(analysis) = cache.get(&uri) else {
return Ok(None);
};
let Some(symbol) = symbol_at_line(analysis, position.line) else {
return Ok(None);
};
let Some((path, line)) = symbol.winner_path.as_ref().zip(symbol.winner_line) else {
return Ok(None);
};
let Ok(target_uri) = Url::from_file_path(path) else {
return Ok(None);
};
Ok(Some(GotoDefinitionResponse::Scalar(Location {
uri: target_uri,
range: Range {
start: Position {
line: line.saturating_sub(1) as u32,
character: 0,
},
end: Position {
line: line.saturating_sub(1) as u32,
character: 0,
},
},
})))
}
}
impl Backend {
async fn analyze_and_publish(&self, uri: &Url, buffer: Option<&str>) {
let Some(path) = uri.to_file_path().ok() else {
return;
};
let analysis = analyze_file(&path, buffer).unwrap_or_default();
{
let mut cache = self.cache.lock().unwrap();
cache.insert(uri.clone(), analysis.clone());
}
let diagnostics: Vec<Diagnostic> = analysis
.diagnostics
.iter()
.map(|diag| Diagnostic {
range: Range {
start: Position {
line: diag.line.unwrap_or(1).saturating_sub(1) as u32,
character: 0,
},
end: Position {
line: diag.line.unwrap_or(1).saturating_sub(1) as u32,
character: 0,
},
},
severity: Some(diag.severity),
code: Some(NumberOrString::String(diag.code.clone())),
message: diag.message.clone(),
..Default::default()
})
.collect();
self.client
.publish_diagnostics(uri.clone(), diagnostics, None)
.await;
}
}
pub fn run_lsp() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime");
runtime.block_on(async {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(|client| Backend {
client,
cache: Mutex::new(HashMap::new()),
});
tower_lsp::Server::new(stdin, stdout, socket)
.serve(service)
.await;
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actions_branch_includes_step_diagnostics() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/actions/.github/workflows/workflow.yaml");
let content = std::fs::read_to_string(&path).unwrap();
let report =
crate::actions::analyze_workflow_with_content(&path, None, Some(&content)).unwrap();
let steps = report.jobs.iter().flat_map(|job| &job.steps).count();
let step_diags: Vec<&str> = report
.jobs
.iter()
.flat_map(|job| job.steps.iter().flat_map(|step| step.diagnostics.iter()))
.map(|diag| diag.code.as_str())
.collect();
assert_eq!(steps, 5, "expected 5 steps, jobs={}", report.jobs.len());
assert!(
step_diags.contains(&"github-env-runtime"),
"step diagnostics missing, got {step_diags:?}"
);
let analysis = analyze_file(&path, Some(&content)).unwrap();
let codes: Vec<&str> = analysis
.diagnostics
.iter()
.map(|diag| diag.code.as_str())
.collect();
assert!(
codes.contains(&"github-env-runtime"),
"step diagnostics missing, got {codes:?}"
);
}
#[test]
fn gitlab_branch_includes_report_diagnostics() {
let path =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gitlab/.gitlab-ci.yml");
let content = std::fs::read_to_string(&path).unwrap();
let analysis = analyze_file(&path, Some(&content)).unwrap();
let codes: Vec<&str> = analysis
.diagnostics
.iter()
.map(|diag| diag.code.as_str())
.collect();
assert!(codes.contains(&"gitlab-include-external"), "got {codes:?}");
}
}