ontocore-lsp 0.12.0

Language server for OntoCode (OntoCore LSP via ontocore-lsp)
Documentation
//! `textDocument/codeAction` from diagnostic quick fixes.

use crate::state::{path_to_uri, ServerState};
use lsp_types::{
    CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, Range, TextEdit, Uri,
    WorkspaceEdit,
};
use ontocore_core::QuickFix;
use ontocore_owl::patch::{apply_patches_to_text, PatchOp};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;

pub fn handle_code_action(
    state: &ServerState,
    params: CodeActionParams,
) -> Option<Vec<CodeActionOrCommand>> {
    let path = state.resolve_lsp_document_uri(params.text_document.uri.as_str()).ok()?;
    let content = state.document_text(&path)?;
    let namespaces = namespaces_for_path(state, &path);
    let mut actions = Vec::new();

    for diag in params.context.diagnostics {
        let fix = diag.data.as_ref().and_then(|v| {
            if let Some(s) = v.as_str() {
                QuickFix::decode(s)
            } else {
                serde_json::from_value::<String>(v.clone()).ok().and_then(|s| QuickFix::decode(&s))
            }
        });

        let Some(fix) = fix else { continue };

        if let Some(action) = quick_fix_to_action(&fix, &path, &content, &namespaces) {
            actions.push(CodeActionOrCommand::CodeAction(action));
        }
    }

    if actions.is_empty() {
        None
    } else {
        Some(actions)
    }
}

fn namespaces_for_path(state: &ServerState, path: &PathBuf) -> BTreeMap<String, String> {
    state
        .with_catalog(|catalog| {
            catalog
                .data()
                .documents
                .iter()
                .find(|d| d.path == *path)
                .map(|d| d.namespaces.clone())
                .unwrap_or_default()
        })
        .unwrap_or_default()
}

fn quick_fix_to_action(
    fix: &QuickFix,
    path: &Path,
    content: &str,
    namespaces: &BTreeMap<String, String>,
) -> Option<CodeAction> {
    match fix {
        QuickFix::InsertText { label, line, column, text } => {
            let line_idx = line.saturating_sub(1) as u32;
            let line_str = content.lines().nth(line_idx as usize)?;
            let character = crate::positions::byte_col_to_utf16(line_str, column.saturating_sub(1));
            let edit = TextEdit {
                range: Range {
                    start: lsp_types::Position { line: line_idx, character },
                    end: lsp_types::Position { line: line_idx, character },
                },
                new_text: text.clone(),
            };
            Some(code_action_with_edit(label, path, vec![edit]))
        }
        QuickFix::RemoveLine { label, line } => {
            let line_idx = line.saturating_sub(1) as u32;
            let mut lines: Vec<&str> = content.lines().collect();
            if line_idx as usize >= lines.len() {
                return None;
            }
            lines.remove(line_idx as usize);
            let new_text = if lines.is_empty() { String::new() } else { lines.join("\n") + "\n" };
            Some(code_action_with_edit(label, path, vec![full_document_edit(content, &new_text)]))
        }
        QuickFix::ApplyPatch { label, document_path, patches } => {
            let patch_ops: Vec<PatchOp> =
                patches.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect();
            if patch_ops.is_empty() {
                return None;
            }
            let doc_path = PathBuf::from(document_path);
            let result = apply_patches_to_text(content, &patch_ops, true, namespaces).ok()?;
            if !result.diagnostics.is_empty() {
                return None;
            }
            let new_text = result.preview_text?;
            Some(code_action_with_edit(
                label,
                &doc_path,
                vec![full_document_edit(content, &new_text)],
            ))
        }
    }
}

fn full_document_edit(old: &str, new_text: &str) -> TextEdit {
    let end_line = old.lines().count().saturating_sub(1) as u32;
    let last_len =
        old.lines().last().map(|l| crate::positions::byte_col_to_utf16(l, l.len())).unwrap_or(0);
    TextEdit {
        range: Range {
            start: lsp_types::Position { line: 0, character: 0 },
            end: lsp_types::Position { line: end_line, character: last_len },
        },
        new_text: new_text.to_string(),
    }
}

fn code_action_with_edit(label: &str, path: &Path, edits: Vec<TextEdit>) -> CodeAction {
    let uri = Uri::from_str(&path_to_uri(path))
        .unwrap_or_else(|_| Uri::from_str("file:///").expect("file uri"));
    CodeAction {
        title: label.to_string(),
        kind: Some(CodeActionKind::QUICKFIX),
        edit: Some(WorkspaceEdit {
            changes: Some(std::collections::HashMap::from([(uri, edits)])),
            ..Default::default()
        }),
        ..Default::default()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::path_to_uri;
    use ontocore_core::QuickFix;
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    #[test]
    fn insert_text_quick_fix_becomes_code_action() {
        let path = PathBuf::from("/tmp/live.ttl");
        let content = "ex:Bad a owl:Class .\n";
        let fix = QuickFix::InsertText {
            label: "Declare @prefix ex:".to_string(),
            line: 1,
            column: 1,
            text: "@prefix ex: <http://example.org/ex#> .\n".to_string(),
        };
        let action = quick_fix_to_action(&fix, &path, content, &BTreeMap::new()).expect("action");
        assert_eq!(action.title, "Declare @prefix ex:");
        assert!(action.edit.is_some());
    }

    #[test]
    fn code_action_uri_uses_path_to_uri() {
        let path = PathBuf::from("/tmp/my ontologies/live.ttl");
        let content = "ex:Bad a owl:Class .\n";
        let fix = QuickFix::InsertText {
            label: "fix".to_string(),
            line: 1,
            column: 1,
            text: "@prefix ex: <http://example.org/ex#> .\n".to_string(),
        };
        let action = quick_fix_to_action(&fix, &path, content, &BTreeMap::new()).expect("action");
        let uri = action
            .edit
            .expect("edit")
            .changes
            .expect("changes")
            .keys()
            .next()
            .expect("uri")
            .to_string();
        assert_eq!(uri, path_to_uri(&path));
    }

    #[test]
    fn apply_patch_quick_fix_skipped_when_patch_has_diagnostics() {
        let path = PathBuf::from("/tmp/live.ttl");
        let content = "@prefix ex: <http://example.org/ex#> .\n\nex:Bad a owl:Class .\n";
        let fix = QuickFix::ApplyPatch {
            label: "Add rdfs:label \"Ghost\"".to_string(),
            document_path: path.display().to_string(),
            patches: vec![serde_json::json!({
                "op": "add_label",
                "entity_iri": "http://example.org/ex#Ghost",
                "value": "Ghost",
            })],
        };
        let mut namespaces = BTreeMap::new();
        namespaces.insert("ex".to_string(), "http://example.org/ex#".to_string());
        assert!(
            quick_fix_to_action(&fix, &path, content, &namespaces).is_none(),
            "missing entity patch must not produce a no-op code action"
        );
    }
}