use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use lsp_types::{
Diagnostic, DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportKind,
DocumentDiagnosticReportPartialResult, DocumentDiagnosticReportResult,
FullDocumentDiagnosticReport, ProgressToken, RelatedFullDocumentDiagnosticReport,
RelatedUnchangedDocumentDiagnosticReport, UnchangedDocumentDiagnosticReport, Uri,
WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, WorkspaceDiagnosticReportPartialResult,
WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport,
WorkspaceFullDocumentDiagnosticReport, WorkspaceUnchangedDocumentDiagnosticReport,
};
use serde::Serialize;
use super::super::conversions::convert_diagnostic;
use crate::lsp::global_state::{GlobalState, StateSnapshot};
use crate::lsp::uri_ext::UriExt;
pub(crate) type Publish = (Uri, Option<i32>, Vec<Diagnostic>);
const WORKSPACE_REPORT_CHUNK_SIZE: usize = 64;
const RELATED_REPORT_CHUNK_SIZE: usize = 64;
pub(crate) struct Streamed<R> {
pub(crate) response: R,
pub(crate) progress: Vec<lsp_server::Notification>,
}
impl<R> Streamed<R> {
fn whole(response: R) -> Self {
Self {
response,
progress: Vec::new(),
}
}
}
fn progress_notification(token: &ProgressToken, value: impl Serialize) -> lsp_server::Notification {
#[derive(Serialize)]
struct Envelope<'a, T> {
token: &'a ProgressToken,
value: T,
}
lsp_server::Notification::new("$/progress".to_owned(), Envelope { token, value })
}
pub(crate) fn manifest_publishes(snap: &StateSnapshot, uri: &Uri) -> (Vec<Publish>, HashSet<Uri>) {
let Some(doc_state) = snap.document_state(uri) else {
return (Vec::new(), HashSet::new());
};
let mut by_path: BTreeMap<PathBuf, Vec<crate::linter::diagnostics::Diagnostic>> =
BTreeMap::new();
let parse_diags = crate::salsa::project_manifest_diagnostics(
snap.db(),
doc_state.salsa_file,
doc_state.salsa_config,
);
for (path, yaml_error) in parse_diags {
let Some(file_text) = snap.db().file_text(path.clone()) else {
continue;
};
let Some(manifest_text) = file_text.text(snap.db()).as_deref() else {
continue;
};
if let Some(diag) =
crate::linter::metadata_diagnostics::yaml_error_diagnostic(yaml_error, manifest_text)
{
by_path.entry(path.clone()).or_default().push(diag);
}
}
let schema_diags = crate::salsa::project_manifest_schema_diagnostics(
snap.db(),
doc_state.salsa_file,
doc_state.salsa_config,
);
for (path, diags) in schema_diags {
by_path
.entry(path.clone())
.or_default()
.extend(diags.iter().cloned());
}
let mut publishes = Vec::new();
let mut manifest_uris = HashSet::new();
for (path, diags) in by_path {
let Some(target_uri) = Uri::from_file_path(&path) else {
continue;
};
let Some(file_text) = snap.db().file_text(path.clone()) else {
continue;
};
let Some(manifest_text) = file_text.text(snap.db()).as_deref() else {
continue;
};
let converted = diags
.iter()
.map(|d| convert_diagnostic(d, manifest_text))
.collect();
publishes.push((target_uri.clone(), None, converted));
manifest_uris.insert(target_uri);
}
(publishes, manifest_uris)
}
pub(crate) fn compute_publishes_with_dependents(
snap: &StateSnapshot,
uri: &Uri,
run_external: bool,
) -> Vec<Publish> {
let mut publishes = Vec::new();
if let Some(state) = snap.document_state(uri)
&& let Some(path) = state.path.as_ref()
{
let graph =
crate::salsa::project_structure(snap.db(), state.salsa_file, state.salsa_config)
.clone();
for dependent in graph.dependents(path, None) {
if let Some(dep_uri) = Uri::from_file_path(&dependent) {
publishes.extend(compute_publishes(snap, &dep_uri, false));
}
}
}
publishes.extend(compute_publishes(snap, uri, run_external));
publishes
}
pub(crate) fn compute_publishes(
snap: &StateSnapshot,
uri: &Uri,
run_external: bool,
) -> Vec<Publish> {
log::debug!(
"compute_publishes uri={} run_external={}",
uri.as_str(),
run_external
);
let Some(doc_state) = snap.document_state(uri) else {
log::warn!("Document not found for lint: {}", uri.as_str());
return Vec::new();
};
let text = doc_state.salsa_file.content_or_empty(snap.db()).to_string();
let lint_plan =
crate::salsa::built_in_lint_plan(snap.db(), doc_state.salsa_file, doc_state.salsa_config)
.clone();
let mut panache_diagnostics = lint_plan.diagnostics;
let external_jobs = lint_plan.external_jobs;
#[cfg(not(target_arch = "wasm32"))]
if run_external && !external_jobs.is_empty() {
let registry = crate::linter::external_linters::ExternalLinterRegistry::new();
for job in &external_jobs {
match crate::linter::external_linters_sync::run_linter_sync(
&job.linter_name,
&job.language,
&job.content,
&text,
®istry,
Some(job.mappings.as_slice()),
) {
Ok(diags) => panache_diagnostics.extend(diags),
Err(e) => log::warn!("External linter failed: {e}"),
}
}
panache_diagnostics.sort_by_key(|d| (d.location.line, d.location.column));
}
let own_diagnostics: Vec<Diagnostic> = panache_diagnostics
.iter()
.map(|d| convert_diagnostic(d, &text))
.collect();
let root_path = uri.to_file_path().map(|p| p.into_owned());
let mut by_path: HashMap<PathBuf, Vec<crate::linter::diagnostics::Diagnostic>> = HashMap::new();
for entry in crate::salsa::project_graph::accumulated::<crate::salsa::GraphDiagnostic>(
snap.db(),
doc_state.salsa_file,
doc_state.salsa_config,
) {
by_path
.entry(entry.0.path.clone())
.or_default()
.push(entry.0.diagnostic.clone());
}
if let Some(root_path) = root_path {
by_path.entry(root_path).or_default();
}
let mut publishes = Vec::new();
let mut published_root = false;
for (path, diags) in by_path {
let target_uri = Uri::from_file_path(&path).unwrap_or_else(|| uri.clone());
let target_text = if target_uri == *uri {
text.clone()
} else {
let Some(target_state) = snap.document_state(&target_uri) else {
continue;
};
target_state
.salsa_file
.content_or_empty(snap.db())
.to_string()
};
let mapped: Vec<Diagnostic> = diags
.iter()
.map(|d| convert_diagnostic(d, &target_text))
.collect();
if target_uri == *uri {
let mut merged = own_diagnostics.clone();
merged.extend(mapped);
publishes.push((uri.clone(), None, merged));
published_root = true;
} else {
publishes.push((target_uri, None, mapped));
}
}
if !published_root {
publishes.push((uri.clone(), None, own_diagnostics));
}
publishes
}
pub(crate) fn document_diagnostic(
gs: &GlobalState,
params: DocumentDiagnosticParams,
) -> Streamed<DocumentDiagnosticReportResult> {
if !gs.supports_pull_diagnostics {
return Streamed::whole(DocumentDiagnosticReportResult::Report(
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport::default(),
}),
));
}
let token = params.partial_result_params.partial_result_token;
let uri = params.text_document.uri;
let related = related_documents(gs, &uri);
let (related, progress) = split_related(token.as_ref(), related);
let report = match gs.diagnostics.get(&uri) {
Some(stored) if params.previous_result_id.as_deref() == Some(stored.result_id.as_str()) => {
DocumentDiagnosticReport::Unchanged(RelatedUnchangedDocumentDiagnosticReport {
related_documents: related,
unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport {
result_id: stored.result_id.clone(),
},
})
}
Some(stored) => DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: related,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: Some(stored.result_id.clone()),
items: stored.items.clone(),
},
}),
None => DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: related,
full_document_diagnostic_report: FullDocumentDiagnosticReport::default(),
}),
};
Streamed {
response: DocumentDiagnosticReportResult::Report(report),
progress,
}
}
fn split_related(
token: Option<&ProgressToken>,
related: Option<HashMap<Uri, DocumentDiagnosticReportKind>>,
) -> (
Option<HashMap<Uri, DocumentDiagnosticReportKind>>,
Vec<lsp_server::Notification>,
) {
let Some(token) = token else {
return (related, Vec::new());
};
let Some(map) = related else {
return (None, Vec::new());
};
if map.len() <= RELATED_REPORT_CHUNK_SIZE {
return ((!map.is_empty()).then_some(map), Vec::new());
}
let mut entries: Vec<(Uri, DocumentDiagnosticReportKind)> = map.into_iter().collect();
let rest = entries.split_off(RELATED_REPORT_CHUNK_SIZE);
let response_map: HashMap<Uri, DocumentDiagnosticReportKind> = entries.into_iter().collect();
let progress = rest
.chunks(RELATED_REPORT_CHUNK_SIZE)
.map(|chunk| {
let chunk_map: HashMap<Uri, DocumentDiagnosticReportKind> =
chunk.iter().cloned().collect();
progress_notification(
token,
DocumentDiagnosticReportPartialResult {
related_documents: Some(chunk_map),
},
)
})
.collect();
(Some(response_map), progress)
}
fn related_documents(
gs: &GlobalState,
uri: &Uri,
) -> Option<HashMap<Uri, DocumentDiagnosticReportKind>> {
if !gs.supports_related_documents {
return None;
}
let snap = gs.snapshot();
let doc_state = snap.document_state(uri)?;
let root = uri.to_file_path()?.into_owned();
let graph =
crate::salsa::project_structure(snap.db(), doc_state.salsa_file, doc_state.salsa_config);
let mut map = HashMap::new();
for path in project_closure(graph, &root) {
let Some(target) = Uri::from_file_path(&path) else {
continue;
};
if target == *uri {
continue;
}
let Some(stored) = gs.diagnostics.get(&target) else {
continue;
};
if stored.items.is_empty() {
continue;
}
map.insert(
target,
DocumentDiagnosticReportKind::Full(FullDocumentDiagnosticReport {
result_id: Some(stored.result_id.clone()),
items: stored.items.clone(),
}),
);
}
(!map.is_empty()).then_some(map)
}
fn project_closure(graph: &crate::salsa::ProjectGraph, root: &PathBuf) -> HashSet<PathBuf> {
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut stack = vec![root.clone()];
while let Some(path) = stack.pop() {
for next in graph
.dependencies(&path, None)
.into_iter()
.chain(graph.dependents(&path, None))
{
if visited.insert(next.clone()) {
stack.push(next);
}
}
}
visited.remove(root);
visited
}
pub(crate) fn workspace_diagnostic(
gs: &GlobalState,
params: WorkspaceDiagnosticParams,
) -> Streamed<WorkspaceDiagnosticReportResult> {
if !gs.supports_pull_diagnostics {
return Streamed::whole(WorkspaceDiagnosticReportResult::Report(
WorkspaceDiagnosticReport { items: Vec::new() },
));
}
let known: HashMap<&Uri, &str> = params
.previous_result_ids
.iter()
.map(|prev| (&prev.uri, prev.value.as_str()))
.collect();
let items: Vec<WorkspaceDocumentDiagnosticReport> = gs
.diagnostics
.iter()
.map(|(uri, stored)| {
if known.get(uri).copied() == Some(stored.result_id.as_str()) {
WorkspaceDocumentDiagnosticReport::Unchanged(
WorkspaceUnchangedDocumentDiagnosticReport {
uri: uri.clone(),
version: stored.version.map(i64::from),
unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport {
result_id: stored.result_id.clone(),
},
},
)
} else {
WorkspaceDocumentDiagnosticReport::Full(WorkspaceFullDocumentDiagnosticReport {
uri: uri.clone(),
version: stored.version.map(i64::from),
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: Some(stored.result_id.clone()),
items: stored.items.clone(),
},
})
}
})
.collect();
let token = params.partial_result_params.partial_result_token;
let (items, progress) = split_workspace_items(token.as_ref(), items);
Streamed {
response: WorkspaceDiagnosticReportResult::Report(WorkspaceDiagnosticReport { items }),
progress,
}
}
fn split_workspace_items(
token: Option<&ProgressToken>,
items: Vec<WorkspaceDocumentDiagnosticReport>,
) -> (
Vec<WorkspaceDocumentDiagnosticReport>,
Vec<lsp_server::Notification>,
) {
let Some(token) = token else {
return (items, Vec::new());
};
let mut chunks = items.chunks(WORKSPACE_REPORT_CHUNK_SIZE);
let first = chunks.next().map(<[_]>::to_vec).unwrap_or_default();
let progress = chunks
.map(|chunk| {
progress_notification(
token,
WorkspaceDiagnosticReportPartialResult {
items: chunk.to_vec(),
},
)
})
.collect();
(first, progress)
}
#[cfg(test)]
mod tests {
use super::*;
fn token() -> ProgressToken {
ProgressToken::Number(1)
}
fn workspace_item(i: usize) -> WorkspaceDocumentDiagnosticReport {
let uri: Uri = format!("file:///doc{i}.qmd").parse().unwrap();
WorkspaceDocumentDiagnosticReport::Full(WorkspaceFullDocumentDiagnosticReport {
uri,
version: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport::default(),
})
}
fn workspace_uris(items: &[WorkspaceDocumentDiagnosticReport]) -> Vec<String> {
items
.iter()
.map(|item| match item {
WorkspaceDocumentDiagnosticReport::Full(full) => full.uri.as_str().to_owned(),
WorkspaceDocumentDiagnosticReport::Unchanged(unchanged) => {
unchanged.uri.as_str().to_owned()
}
})
.collect()
}
fn related_map(n: usize) -> HashMap<Uri, DocumentDiagnosticReportKind> {
(0..n)
.map(|i| {
let uri: Uri = format!("file:///rel{i}.qmd").parse().unwrap();
(
uri,
DocumentDiagnosticReportKind::Full(FullDocumentDiagnosticReport::default()),
)
})
.collect()
}
fn workspace_chunk_items(
note: &lsp_server::Notification,
) -> Vec<WorkspaceDocumentDiagnosticReport> {
assert_eq!(note.method, "$/progress");
let value = note.params.get("value").unwrap().clone();
serde_json::from_value::<WorkspaceDiagnosticReportPartialResult>(value)
.unwrap()
.items
}
fn related_chunk_keys(note: &lsp_server::Notification) -> Vec<String> {
assert_eq!(note.method, "$/progress");
let value = note.params.get("value").unwrap().clone();
serde_json::from_value::<DocumentDiagnosticReportPartialResult>(value)
.unwrap()
.related_documents
.unwrap()
.keys()
.map(|u| u.as_str().to_owned())
.collect()
}
#[test]
fn workspace_no_token_keeps_everything_in_response() {
let items: Vec<_> = (0..WORKSPACE_REPORT_CHUNK_SIZE + 5)
.map(workspace_item)
.collect();
let expected = workspace_uris(&items);
let (first, progress) = split_workspace_items(None, items);
assert!(progress.is_empty(), "no token => no streaming");
assert_eq!(workspace_uris(&first), expected);
}
#[test]
fn workspace_single_chunk_emits_no_progress() {
let items: Vec<_> = (0..WORKSPACE_REPORT_CHUNK_SIZE)
.map(workspace_item)
.collect();
let expected = workspace_uris(&items);
let (first, progress) = split_workspace_items(Some(&token()), items);
assert!(
progress.is_empty(),
"exactly one chunk fits in the response"
);
assert_eq!(workspace_uris(&first), expected);
}
#[test]
fn workspace_multi_chunk_preserves_every_report() {
let total = WORKSPACE_REPORT_CHUNK_SIZE * 2 + 3;
let items: Vec<_> = (0..total).map(workspace_item).collect();
let expected = workspace_uris(&items);
let (first, progress) = split_workspace_items(Some(&token()), items);
assert_eq!(
first.len(),
WORKSPACE_REPORT_CHUNK_SIZE,
"first chunk is full"
);
assert_eq!(progress.len(), 2, "two streamed chunks for 2*N+3 items");
let mut seen = workspace_uris(&first);
for note in &progress {
let chunk = workspace_chunk_items(note);
assert!(
chunk.len() <= WORKSPACE_REPORT_CHUNK_SIZE,
"no chunk exceeds the chunk size"
);
seen.extend(workspace_uris(&chunk));
}
assert_eq!(seen, expected, "union of response + chunks == whole report");
}
#[test]
fn related_no_token_keeps_whole_map() {
let map = related_map(RELATED_REPORT_CHUNK_SIZE + 5);
let expected = map.len();
let (response, progress) = split_related(None, Some(map));
assert!(progress.is_empty());
assert_eq!(response.unwrap().len(), expected);
}
#[test]
fn related_none_stays_none() {
let (response, progress) = split_related(Some(&token()), None);
assert!(response.is_none());
assert!(progress.is_empty());
}
#[test]
fn related_single_chunk_emits_no_progress() {
let map = related_map(RELATED_REPORT_CHUNK_SIZE);
let (response, progress) = split_related(Some(&token()), Some(map));
assert_eq!(response.unwrap().len(), RELATED_REPORT_CHUNK_SIZE);
assert!(progress.is_empty());
}
#[test]
fn related_multi_chunk_preserves_every_entry() {
let total = RELATED_REPORT_CHUNK_SIZE * 2 + 3;
let map = related_map(total);
let expected: HashSet<String> = map.keys().map(|u| u.as_str().to_owned()).collect();
let (response, progress) = split_related(Some(&token()), Some(map));
let response = response.expect("first chunk rides in the response");
assert_eq!(
response.len(),
RELATED_REPORT_CHUNK_SIZE,
"first chunk is full"
);
assert_eq!(progress.len(), 2, "two streamed chunks for 2*N+3 entries");
let mut seen: HashSet<String> = response.keys().map(|u| u.as_str().to_owned()).collect();
for note in &progress {
let keys = related_chunk_keys(note);
assert!(
keys.len() <= RELATED_REPORT_CHUNK_SIZE,
"no chunk exceeds the size"
);
seen.extend(keys);
}
assert_eq!(seen, expected, "union of response + chunks == whole map");
}
}