achitek-ls 0.2.0

achitekfile language server
Documentation
//! LSP diagnostic publishing.
//!
//! This module is intentionally an adapter: Achitekfile and Tera diagnostics
//! are produced by their language crates, then converted into LSP diagnostics
//! here.

use crate::{
    lsp::project_diagnostics,
    server::{ServerState, project::ProjectContext},
    workspace::DocumentKind,
};
use anyhow::Context;
use lsp_server::{Connection, Message, Notification};
use lsp_types::{
    Diagnostic as LspDiagnostic, DiagnosticSeverity, NumberOrString, Position,
    PublishDiagnosticsParams, Range, Uri,
};

pub fn publish(connection: &Connection, uri: &Uri, state: &ServerState) -> anyhow::Result<()> {
    let Some(document) = state.documents.get(uri.as_str()) else {
        return Ok(());
    };

    let mut diagnostics = diagnostics_for_document(state.document_kind(uri), uri, &document.text)?;
    match state.document_kind(uri) {
        DocumentKind::Achitekfile => {
            diagnostics.extend(project_diagnostics::achitekfile_diagnostics(uri, state)?);
        }
        DocumentKind::TeraTemplate => {
            diagnostics.extend(project_diagnostics::template_project_diagnostics(
                uri, state,
            )?);
        }
        DocumentKind::Manifest | DocumentKind::Unknown => {}
    }
    tracing::debug!(
        ?uri,
        version = document.version,
        count = diagnostics.len(),
        "publishing document diagnostics"
    );

    send_publish_diagnostics(
        connection,
        PublishDiagnosticsParams {
            uri: uri.clone(),
            diagnostics,
            version: Some(document.version),
        },
    )
}

pub fn publish_after_document_update(
    connection: &Connection,
    uri: &Uri,
    state: &ServerState,
) -> anyhow::Result<()> {
    publish(connection, uri, state)?;

    match state.document_kind(uri) {
        DocumentKind::Achitekfile => publish_templates(connection, uri, state)?,
        DocumentKind::TeraTemplate => publish_achitekfile_for_project(connection, uri, state)?,
        DocumentKind::Manifest | DocumentKind::Unknown => {}
    }

    Ok(())
}

pub fn publish_templates(
    connection: &Connection,
    uri: &Uri,
    state: &ServerState,
) -> anyhow::Result<()> {
    for (template_uri, diagnostics) in project_diagnostics::template_diagnostics(uri, state)? {
        tracing::debug!(
            ?uri,
            ?template_uri,
            count = diagnostics.len(),
            "publishing template diagnostics"
        );
        send_publish_diagnostics(
            connection,
            PublishDiagnosticsParams {
                uri: template_uri,
                diagnostics,
                version: None,
            },
        )?;
    }

    Ok(())
}

pub fn publish_achitekfile_for_project(
    connection: &Connection,
    uri: &Uri,
    state: &ServerState,
) -> anyhow::Result<()> {
    let Some(project) = ProjectContext::for_uri(state, uri) else {
        return Ok(());
    };

    let achitek_uri = project.achitekfile_uri()?;
    let source = project.achitekfile_source()?;
    let mut diagnostics =
        diagnostics_for_document(DocumentKind::Achitekfile, &achitek_uri, &source)?;
    diagnostics.extend(project_diagnostics::achitekfile_diagnostics(
        &achitek_uri,
        state,
    )?);
    let version = state
        .documents
        .get(achitek_uri.as_str())
        .map(|document| document.version);

    tracing::debug!(
        ?uri,
        ?achitek_uri,
        count = diagnostics.len(),
        "publishing project Achitekfile diagnostics"
    );
    send_publish_diagnostics(
        connection,
        PublishDiagnosticsParams {
            uri: achitek_uri,
            diagnostics,
            version,
        },
    )
}

pub fn clear(connection: &Connection, uri: &Uri) -> anyhow::Result<()> {
    tracing::debug!(?uri, "clearing diagnostics");
    send_publish_diagnostics(
        connection,
        PublishDiagnosticsParams {
            uri: uri.clone(),
            diagnostics: Vec::new(),
            version: None,
        },
    )
}

fn diagnostics_for_document(
    kind: DocumentKind,
    uri: &Uri,
    source: &str,
) -> anyhow::Result<Vec<LspDiagnostic>> {
    match kind {
        DocumentKind::TeraTemplate => {
            let analysis = terafile::analyze(source)
                .with_context(|| format!("failed to analyze `{uri:?}`"))?;
            Ok(analysis
                .diagnostics()
                .iter()
                .map(to_tera_lsp_diagnostic)
                .collect())
        }
        DocumentKind::Achitekfile => {
            let analysis = achitekfile::analyze(source)
                .with_context(|| format!("failed to analyze `{uri:?}`"))?;
            Ok(analysis
                .diagnostics()
                .iter()
                .map(to_achitek_lsp_diagnostic)
                .collect())
        }
        DocumentKind::Manifest | DocumentKind::Unknown => Ok(Vec::new()),
    }
}

fn send_publish_diagnostics(
    connection: &Connection,
    params: PublishDiagnosticsParams,
) -> anyhow::Result<()> {
    let notification = Notification::new("textDocument/publishDiagnostics".to_owned(), params);
    connection
        .sender
        .send(Message::Notification(notification))
        .context("failed to send publishDiagnostics notification")?;

    Ok(())
}

fn to_achitek_lsp_diagnostic(diagnostic: &achitekfile::Diagnostic) -> LspDiagnostic {
    LspDiagnostic {
        range: to_achitek_lsp_range(diagnostic.range()),
        severity: Some(to_achitek_lsp_severity(diagnostic.severity())),
        code: Some(NumberOrString::String(
            diagnostic.code().as_str().to_owned(),
        )),
        message: diagnostic.message().to_owned(),
        ..LspDiagnostic::default()
    }
}

fn to_tera_lsp_diagnostic(diagnostic: &terafile::Diagnostic) -> LspDiagnostic {
    LspDiagnostic {
        range: to_tera_lsp_range(diagnostic.range()),
        severity: Some(to_tera_lsp_severity(diagnostic.severity())),
        code: Some(NumberOrString::String(
            diagnostic.code().as_str().to_owned(),
        )),
        message: diagnostic.message().to_owned(),
        ..LspDiagnostic::default()
    }
}

fn to_achitek_lsp_severity(severity: achitekfile::Severity) -> DiagnosticSeverity {
    match severity {
        achitekfile::Severity::Error => DiagnosticSeverity::ERROR,
        achitekfile::Severity::Warning => DiagnosticSeverity::WARNING,
        achitekfile::Severity::Hint => DiagnosticSeverity::HINT,
    }
}

fn to_tera_lsp_severity(severity: terafile::Severity) -> DiagnosticSeverity {
    match severity {
        terafile::Severity::Error => DiagnosticSeverity::ERROR,
        terafile::Severity::Warning => DiagnosticSeverity::WARNING,
        terafile::Severity::Hint => DiagnosticSeverity::HINT,
    }
}

fn to_achitek_lsp_range(range: achitekfile::TextRange) -> Range {
    Range {
        start: to_achitek_lsp_position(range.start),
        end: to_achitek_lsp_position(range.end),
    }
}

fn to_achitek_lsp_position(position: achitekfile::TextPosition) -> Position {
    Position {
        line: u32::try_from(position.line).expect("line should fit into u32"),
        character: u32::try_from(position.byte).expect("column should fit into u32"),
    }
}

fn to_tera_lsp_range(range: terafile::TextRange) -> Range {
    Range {
        start: to_tera_lsp_position(range.start),
        end: to_tera_lsp_position(range.end),
    }
}

fn to_tera_lsp_position(position: terafile::TextPosition) -> Position {
    Position {
        line: u32::try_from(position.line).expect("line should fit into u32"),
        character: u32::try_from(position.byte).expect("column should fit into u32"),
    }
}