ontocore-lsp 0.11.0

Language server for OntoCode (OntoCore LSP via ontocore-lsp)
Documentation
//! `textDocument/completion` for Turtle ontology files.

use crate::state::ServerState;
use lsp_types::{
    CompletionItem, CompletionItemKind, CompletionParams, CompletionResponse, InsertTextFormat,
};
use ontocore_core::EntityKind;
use std::collections::BTreeSet;

const MAX_COMPLETION_ITEMS: usize = 100;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompletionContext {
    PrefixDeclaration,
    QNamePrefix,
    QNameLocal,
    IriBracket,
    None,
}

pub fn handle_completion(
    state: &ServerState,
    params: CompletionParams,
) -> Option<CompletionResponse> {
    let path = state
        .resolve_lsp_document_uri(params.text_document_position.text_document.uri.as_str())
        .ok()?;
    let content = state.document_text(&path)?;
    let position = params.text_document_position.position;
    let line_idx = position.line as usize;
    let line = content.lines().nth(line_idx)?;
    let byte_col = utf16_col_to_byte(line, position.character);
    let prefix = line.get(..byte_col).unwrap_or(line);
    let context = detect_context(line, byte_col, prefix);

    let mut items = match context {
        CompletionContext::PrefixDeclaration => prefix_declaration_items(prefix, state),
        CompletionContext::QNamePrefix => qname_prefix_items(state),
        CompletionContext::QNameLocal => {
            qname_local_items(prefix, state, &content, line_idx, byte_col)
        }
        CompletionContext::IriBracket => iri_bracket_items(prefix, state),
        CompletionContext::None => Vec::new(),
    };

    if items.len() > MAX_COMPLETION_ITEMS {
        items.truncate(MAX_COMPLETION_ITEMS);
    }

    if items.is_empty() {
        return None;
    }

    Some(CompletionResponse::Array(items))
}

fn utf16_col_to_byte(line: &str, utf16_col: u32) -> usize {
    let mut utf16 = 0u32;
    let mut byte = 0usize;
    for ch in line.chars() {
        if utf16 >= utf16_col {
            break;
        }
        utf16 += ch.len_utf16() as u32;
        byte += ch.len_utf8();
    }
    byte
}

fn detect_context(line: &str, byte_col: usize, prefix: &str) -> CompletionContext {
    let trimmed = line.trim_start();
    if trimmed.starts_with("@prefix") || trimmed.starts_with("@PREFIX") {
        return CompletionContext::PrefixDeclaration;
    }
    if prefix.contains('<') && !prefix.rsplit('<').next().is_some_and(|s| s.contains('>')) {
        return CompletionContext::IriBracket;
    }
    if let Some(colon) = prefix.rfind(':') {
        let before = &prefix[..colon];
        if !before.contains(|c: char| c.is_whitespace() || c == ';' || c == ',') {
            let after_colon = &prefix[colon + 1..];
            if !after_colon
                .contains(|c: char| c.is_whitespace() || c == ';' || c == '.' || c == ',')
            {
                return CompletionContext::QNameLocal;
            }
            return CompletionContext::QNamePrefix;
        }
    }
    let _ = byte_col;
    CompletionContext::None
}

fn prefix_declaration_items(prefix: &str, state: &ServerState) -> Vec<CompletionItem> {
    let mut seen = BTreeSet::new();
    let mut items = Vec::new();

    if prefix.trim_end().ends_with('@') || prefix.contains("@prefix") || prefix.contains("@PREFIX")
    {
        for ns in catalog_namespace_iris(state) {
            if seen.insert(ns.clone()) {
                items.push(completion_text(&ns, &format!("<{ns}> ."), CompletionItemKind::VALUE));
            }
        }
    }
    items
}

fn qname_prefix_items(state: &ServerState) -> Vec<CompletionItem> {
    let mut items = Vec::new();
    let mut seen = BTreeSet::new();
    for prefix in catalog_prefixes(state) {
        if seen.insert(prefix.clone()) {
            items.push(completion_text(
                &format!("{prefix}:"),
                &format!("{prefix}:"),
                CompletionItemKind::VARIABLE,
            ));
        }
    }
    items
}

fn qname_local_items(
    prefix_line: &str,
    state: &ServerState,
    _content: &str,
    _line_idx: usize,
    byte_col: usize,
) -> Vec<CompletionItem> {
    let Some(colon) = prefix_line[..byte_col].rfind(':') else {
        return Vec::new();
    };
    let qprefix = &prefix_line[..colon];
    let partial = prefix_line[colon + 1..byte_col].trim_start();

    state
        .with_catalog(|catalog| {
            let mut items = Vec::new();
            for entity in &catalog.data().entities {
                let local = entity.short_name.as_str();
                if !partial.is_empty()
                    && !local.to_ascii_lowercase().starts_with(&partial.to_ascii_lowercase())
                {
                    continue;
                }
                let insert = format!("{qprefix}:{local}");
                let kind = match entity.kind {
                    EntityKind::Class => CompletionItemKind::CLASS,
                    EntityKind::ObjectProperty
                    | EntityKind::DataProperty
                    | EntityKind::AnnotationProperty => CompletionItemKind::PROPERTY,
                    EntityKind::Individual => CompletionItemKind::VALUE,
                    _ => CompletionItemKind::TEXT,
                };
                items.push(completion_text(
                    &format!("{} ({})", local, entity.kind.as_str()),
                    &insert,
                    kind,
                ));
                if items.len() >= MAX_COMPLETION_ITEMS {
                    break;
                }
            }
            items
        })
        .unwrap_or_default()
}

fn iri_bracket_items(prefix: &str, state: &ServerState) -> Vec<CompletionItem> {
    let partial = prefix.rsplit('<').next().unwrap_or("").trim_start_matches('<');
    let mut items = Vec::new();
    state.with_catalog(|catalog| {
        for entity in &catalog.data().entities {
            if !partial.is_empty()
                && !entity.iri.to_ascii_lowercase().contains(&partial.to_ascii_lowercase())
            {
                continue;
            }
            items.push(completion_text(
                &entity.iri,
                &format!("{}>", entity.iri),
                CompletionItemKind::TEXT,
            ));
            if items.len() >= MAX_COMPLETION_ITEMS {
                break;
            }
        }
    });
    items
}

fn catalog_prefixes(state: &ServerState) -> Vec<String> {
    state
        .with_catalog(|catalog| {
            let mut prefixes = BTreeSet::new();
            for doc in &catalog.data().documents {
                for prefix in doc.namespaces.keys() {
                    prefixes.insert(prefix.clone());
                }
            }
            prefixes.into_iter().collect()
        })
        .unwrap_or_default()
}

fn catalog_namespace_iris(state: &ServerState) -> Vec<String> {
    state
        .with_catalog(|catalog| {
            let mut iris = BTreeSet::new();
            for doc in &catalog.data().documents {
                for iri in doc.namespaces.values() {
                    iris.insert(iri.clone());
                }
                if let Some(base) = &doc.base_iri {
                    iris.insert(base.clone());
                }
            }
            for entity in &catalog.data().entities {
                iris.insert(entity.iri.clone());
            }
            iris.into_iter().collect()
        })
        .unwrap_or_default()
}

fn completion_text(label: &str, insert: &str, kind: CompletionItemKind) -> CompletionItem {
    CompletionItem {
        label: label.to_string(),
        kind: Some(kind),
        insert_text: Some(insert.to_string()),
        insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
        ..Default::default()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_prefix_declaration_context() {
        let line = "@prefix ex: ";
        assert_eq!(detect_context(line, line.len(), line), CompletionContext::PrefixDeclaration);
    }

    #[test]
    fn detects_qname_local_context() {
        let line = "ex:Per";
        let prefix = "ex:Per";
        assert_eq!(detect_context(line, prefix.len(), prefix), CompletionContext::QNameLocal);
    }
}