use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
use tower_lsp_server::ls_types::{Diagnostic, Uri};
use crate::analysis::semantic_diagnostics::issues_to_diagnostics_gated;
use crate::document::ast::ParsedDoc;
use crate::document::document_store::DocumentStore;
use crate::lang::config::DiagnosticsConfig;
#[derive(Default, Clone)]
pub(crate) struct OpenFile {
pub(crate) text: Arc<str>,
pub(crate) version: u64,
pub(crate) parse_diagnostics: Vec<Diagnostic>,
pub(crate) published_hash: Option<u64>,
pub(crate) external_diagnostics: Vec<Diagnostic>,
pub(crate) external_diagnostics_version: Option<u64>,
}
#[derive(Clone, Default)]
pub struct OpenFiles {
files: Arc<DashMap<Uri, OpenFile>>,
lifecycle_intents: Arc<DashMap<Uri, LifecycleIntent>>,
next_lifecycle_generation: Arc<AtomicU64>,
}
#[derive(Clone, Copy)]
struct LifecycleIntent {
generation: u64,
is_open: bool,
in_flight: usize,
}
impl OpenFiles {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn set_open_text(&self, docs: &DocumentStore, uri: Uri, text: String) -> u64 {
let text: Arc<str> = Arc::from(text);
docs.mirror_text_arc(&uri, Arc::clone(&text));
let mut entry = self.files.entry(uri).or_default();
entry.version += 1;
entry.text = text;
entry.version
}
pub(crate) fn close(&self, docs: &DocumentStore, uri: &Uri) {
self.files.remove(uri);
docs.evict_token_cache(uri);
}
pub(crate) fn current_version(&self, uri: &Uri) -> Option<u64> {
self.files.get(uri).map(|e| e.version)
}
pub(crate) fn text(&self, uri: &Uri) -> Option<Arc<str>> {
self.files.get(uri).map(|e| Arc::clone(&e.text))
}
pub(crate) fn set_parse_diagnostics(&self, uri: &Uri, diagnostics: Vec<Diagnostic>) {
if let Some(mut entry) = self.files.get_mut(uri) {
entry.parse_diagnostics = diagnostics;
}
}
pub(crate) fn parse_diagnostics(&self, uri: &Uri) -> Option<Vec<Diagnostic>> {
self.files.get(uri).map(|e| e.parse_diagnostics.clone())
}
pub(crate) fn note_published(&self, uri: &Uri, hash: u64) {
if let Some(mut entry) = self.files.get_mut(uri) {
entry.published_hash = Some(hash);
}
}
pub(crate) fn published_hash(&self, uri: &Uri) -> Option<u64> {
self.files.get(uri).and_then(|e| e.published_hash)
}
pub(crate) fn set_external_diagnostics(
&self,
uri: &Uri,
version: u64,
diagnostics: Vec<Diagnostic>,
) {
if let Some(mut entry) = self.files.get_mut(uri) {
entry.external_diagnostics = diagnostics;
entry.external_diagnostics_version = Some(version);
}
}
pub(crate) fn external_diagnostics(&self, uri: &Uri) -> Vec<Diagnostic> {
self.files
.get(uri)
.filter(|e| e.external_diagnostics_version == Some(e.version))
.map(|e| e.external_diagnostics.clone())
.unwrap_or_default()
}
pub(crate) fn all_with_versions(&self) -> Vec<(Uri, Option<i64>)> {
self.files
.iter()
.map(|e| (e.key().clone(), Some(e.value().version as i64)))
.collect()
}
pub(crate) fn urls(&self) -> Vec<Uri> {
self.files.iter().map(|e| e.key().clone()).collect()
}
pub(crate) fn contains(&self, uri: &Uri) -> bool {
self.files.contains_key(uri)
}
pub(crate) fn note_lifecycle_intent(&self, uri: Uri, is_open: bool) -> u64 {
let generation = self
.next_lifecycle_generation
.fetch_add(1, Ordering::Relaxed);
self.lifecycle_intents
.entry(uri)
.and_modify(|intent| {
intent.generation = generation;
intent.is_open = is_open;
intent.in_flight += 1;
})
.or_insert(LifecycleIntent {
generation,
is_open,
in_flight: 1,
});
generation
}
pub(crate) fn finish_lifecycle_intent(&self, uri: &Uri) {
let should_remove = self
.lifecycle_intents
.get_mut(uri)
.is_some_and(|mut intent| {
intent.in_flight -= 1;
intent.in_flight == 0
});
if should_remove {
self.lifecycle_intents
.remove_if(uri, |_, intent| intent.in_flight == 0);
}
}
pub(crate) fn should_apply_lifecycle(&self, uri: &Uri, generation: u64, is_open: bool) -> bool {
self.lifecycle_intents
.get(uri)
.is_some_and(|intent| intent.generation == generation && intent.is_open == is_open)
}
pub(crate) fn lifecycle_intent_count(&self) -> usize {
self.lifecycle_intents.len()
}
pub(crate) fn get_doc(&self, docs: &DocumentStore, uri: &Uri) -> Option<Arc<ParsedDoc>> {
if !self.contains(uri) {
return None;
}
docs.get_doc_salsa(uri)
}
pub(crate) fn get_doc_stale(&self, docs: &DocumentStore, uri: &Uri) -> Option<Arc<ParsedDoc>> {
if !self.contains(uri) {
return None;
}
docs.get_doc_snapshot_or_stale(uri)
}
}
pub(crate) fn compute_open_file_diagnostics(
docs: &DocumentStore,
open_files: &OpenFiles,
uri: &Uri,
diag_cfg: &DiagnosticsConfig,
is_laravel: bool,
) -> Vec<Diagnostic> {
if crate::index::matches_any(uri.path().as_str(), &diag_cfg.exclude_paths) {
return Vec::new();
}
let mut out = open_files.parse_diagnostics(uri).unwrap_or_default();
if let Some(issues) = docs.get_semantic_issues_salsa(uri) {
out.extend(issues_to_diagnostics_gated(
&issues,
uri,
diag_cfg,
docs.is_index_ready(),
));
}
out.extend(crate::laravel::unguarded_model_diagnostics(
docs, uri, is_laravel,
));
out.extend(open_files.external_diagnostics(uri));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_reuses_the_same_arc_across_reads() {
let docs = DocumentStore::new();
let open_files = OpenFiles::new();
let uri = ("file:///test.php").parse::<Uri>().unwrap();
open_files.set_open_text(&docs, uri.clone(), "<?php".to_owned());
let first = open_files.text(&uri).unwrap();
let second = open_files.text(&uri).unwrap();
assert!(
Arc::ptr_eq(&first, &second),
"repeated reads must share one allocation, not clone the buffer per call"
);
}
#[test]
fn reopen_supersedes_an_earlier_close_at_transport_ingress() {
let open_files = OpenFiles::new();
let uri = "file:///reopen.php".parse::<Uri>().unwrap();
let close_generation = open_files.note_lifecycle_intent(uri.clone(), false);
let reopen_generation = open_files.note_lifecycle_intent(uri.clone(), true);
assert!(open_files.should_apply_lifecycle(&uri, reopen_generation, true));
assert!(
!open_files.should_apply_lifecycle(&uri, close_generation, false),
"a close received before a reopen must not apply after it"
);
open_files.finish_lifecycle_intent(&uri);
open_files.finish_lifecycle_intent(&uri);
assert_eq!(open_files.lifecycle_intent_count(), 0);
}
#[test]
fn undefined_class_suppressed_until_index_ready() {
let docs = DocumentStore::new();
let open_files = OpenFiles::new();
let uri = ("file:///app.php").parse::<Uri>().unwrap();
open_files.set_open_text(
&docs,
uri.clone(),
"<?php\n\nnew TrulyMissingClass();\n".to_owned(),
);
let diag_cfg = DiagnosticsConfig::default();
let before = compute_open_file_diagnostics(&docs, &open_files, &uri, &diag_cfg, false);
assert!(
before.is_empty(),
"UndefinedClass must be suppressed before the index is ready, got: {before:?}"
);
docs.mark_index_ready();
let after = compute_open_file_diagnostics(&docs, &open_files, &uri, &diag_cfg, false);
assert!(
after
.iter()
.any(|d| d.message.contains("TrulyMissingClass")),
"UndefinedClass must be reported once the index is ready, got: {after:?}"
);
}
#[test]
fn local_only_issue_not_suppressed_before_index_ready() {
let docs = DocumentStore::new();
let open_files = OpenFiles::new();
let uri = ("file:///app.php").parse::<Uri>().unwrap();
open_files.set_open_text(
&docs,
uri.clone(),
"<?php\nfunction f(): string { return 42; }\n".to_owned(),
);
let diag_cfg = DiagnosticsConfig::default();
let before = compute_open_file_diagnostics(&docs, &open_files, &uri, &diag_cfg, false);
assert!(
!before.is_empty(),
"a same-file return-type mismatch must not wait on the workspace index"
);
}
#[test]
fn exclude_paths_suppresses_all_diagnostics_for_matching_files() {
let docs = DocumentStore::new();
let open_files = OpenFiles::new();
let src = "<?php\nfunction f(): string { return 42; }\n";
let vendor = ("file:///proj/vendor/acme/Lib.php").parse::<Uri>().unwrap();
let app = ("file:///proj/src/App.php").parse::<Uri>().unwrap();
open_files.set_open_text(&docs, vendor.clone(), src.to_owned());
open_files.set_open_text(&docs, app.clone(), src.to_owned());
open_files.set_parse_diagnostics(&vendor, vec![Diagnostic::default()]);
let diag_cfg = DiagnosticsConfig {
exclude_paths: vec!["vendor/*".to_string()],
..DiagnosticsConfig::default()
};
assert!(
compute_open_file_diagnostics(&docs, &open_files, &vendor, &diag_cfg, false).is_empty()
);
assert!(
!compute_open_file_diagnostics(&docs, &open_files, &app, &diag_cfg, false).is_empty()
);
}
}