runmat-lsp 0.5.0

Language Server Protocol implementation for RunMat editors and tooling
use lsp_types::{SymbolInformation, Url};
use serde_json::json;

use crate::core::analysis::{
    analyze_document_with_compat_and_source_async, document_symbols, CompatMode, DocumentAnalysis,
};
use crate::core::project::ProjectContext;
use std::collections::HashSet;

pub fn workspace_symbols_with_project(
    docs: &[(Url, String, DocumentAnalysis)],
    compat: CompatMode,
    query: Option<&str>,
) -> Vec<SymbolInformation> {
    let mut out = workspace_symbols_from_docs(docs);
    #[cfg(target_arch = "wasm32")]
    let _ = compat;
    #[cfg(not(target_arch = "wasm32"))]
    {
        let source_hint = docs
            .first()
            .and_then(|(uri, _, _)| source_name_from_uri(uri));
        if let Some(project) = ProjectContext::discover_from_source_name(source_hint.as_deref()) {
            out.extend(project_symbols_sync(&project, compat));
        }
    }
    dedupe_symbol_info(&mut out);
    filter_symbols(out, query)
}

pub fn workspace_symbols_from_documents(
    docs: &[(Url, String, DocumentAnalysis)],
    query: Option<&str>,
) -> Vec<SymbolInformation> {
    let mut out = workspace_symbols_from_docs(docs);
    dedupe_symbol_info(&mut out);
    filter_symbols(out, query)
}

#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub async fn workspace_symbols_with_project_async(
    docs: &[(Url, String, DocumentAnalysis)],
    compat: CompatMode,
    query: Option<&str>,
) -> Vec<SymbolInformation> {
    let mut out = workspace_symbols_from_docs(docs);
    let source_hint = docs
        .first()
        .and_then(|(uri, _, _)| source_name_from_uri(uri));
    if let Some(project) =
        ProjectContext::discover_from_source_name_async(source_hint.as_deref()).await
    {
        out.extend(project_symbols_async(&project, compat).await);
    }
    dedupe_symbol_info(&mut out);
    filter_symbols(out, query)
}

fn workspace_symbols_from_docs(docs: &[(Url, String, DocumentAnalysis)]) -> Vec<SymbolInformation> {
    let mut out = Vec::new();
    for (uri, text, analysis) in docs {
        append_document_symbols(&mut out, uri, text, analysis);
    }
    out
}

fn append_document_symbols(
    out: &mut Vec<SymbolInformation>,
    uri: &Url,
    text: &str,
    analysis: &DocumentAnalysis,
) {
    for sym in document_symbols(text, analysis) {
        if let Ok(si) = serde_json::from_value::<SymbolInformation>(json!({
            "name": sym.name,
            "kind": sym.kind,
            "tags": sym.tags,
            "location": {
                "uri": uri,
                "range": sym.range,
            },
            "containerName": serde_json::Value::Null,
        })) {
            out.push(si);
        }
    }
}

#[cfg(not(target_arch = "wasm32"))]
fn project_symbols_sync(project: &ProjectContext, compat: CompatMode) -> Vec<SymbolInformation> {
    let mut out = Vec::new();
    for source in project.all_source_files() {
        let Some(uri) = file_path_to_url(source) else {
            continue;
        };
        let Some(text) =
            futures::executor::block_on(runmat_filesystem::read_to_string_async(source)).ok()
        else {
            continue;
        };
        let source_name = source.to_str();
        let analysis = crate::core::analysis::analyze_document_with_compat_and_source(
            &text,
            compat,
            source_name,
        );
        append_document_symbols(&mut out, &uri, &text, &analysis);
    }
    out
}

#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
async fn project_symbols_async(
    project: &ProjectContext,
    compat: CompatMode,
) -> Vec<SymbolInformation> {
    let mut out = Vec::new();
    for source in project.all_source_files() {
        let Some(uri) = file_path_to_url(source) else {
            continue;
        };
        let Ok(text) = runmat_filesystem::read_to_string_async(source).await else {
            continue;
        };
        let source_name = source.to_str();
        let analysis =
            analyze_document_with_compat_and_source_async(&text, compat, source_name).await;
        append_document_symbols(&mut out, &uri, &text, &analysis);
    }
    out
}

fn source_name_from_uri(uri: &Url) -> Option<String> {
    #[cfg(not(target_arch = "wasm32"))]
    {
        uri.to_file_path()
            .ok()
            .and_then(|path| path.to_str().map(str::to_owned))
    }
    #[cfg(target_arch = "wasm32")]
    {
        if uri.scheme() != "file" {
            return None;
        }
        let path = uri.path();
        if path.is_empty() {
            None
        } else {
            Some(path.to_string())
        }
    }
}

fn dedupe_symbol_info(symbols: &mut Vec<SymbolInformation>) {
    let mut seen = HashSet::new();
    symbols.retain(|symbol| {
        let key = format!(
            "{}:{}:{}:{}:{}:{}",
            symbol.name,
            symbol.location.uri,
            symbol.location.range.start.line,
            symbol.location.range.start.character,
            symbol.location.range.end.line,
            symbol.location.range.end.character
        );
        seen.insert(key)
    });
}

fn filter_symbols(symbols: Vec<SymbolInformation>, query: Option<&str>) -> Vec<SymbolInformation> {
    let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
        return symbols;
    };
    let query = query.to_ascii_lowercase();
    symbols
        .into_iter()
        .filter(|symbol| symbol.name.to_ascii_lowercase().contains(&query))
        .collect()
}

fn file_path_to_url(path: &std::path::Path) -> Option<Url> {
    #[cfg(not(target_arch = "wasm32"))]
    {
        Url::from_file_path(path).ok()
    }
    #[cfg(target_arch = "wasm32")]
    {
        let raw = path.to_str()?;
        let normalized = if raw.starts_with('/') {
            raw.to_string()
        } else {
            format!("/{raw}")
        };
        Url::parse(&format!("file://{normalized}")).ok()
    }
}