rlsp-yaml 0.10.0

A fast, lightweight YAML language server
Documentation
// SPDX-License-Identifier: MIT

use tower_lsp::lsp_types::{
    CodeAction, CodeActionKind, Diagnostic, NumberOrString, TextEdit, WorkspaceEdit,
};

use std::collections::HashMap;

use rlsp_yaml_parser::{Document, Span};

use block_scalar::string_to_block_scalar;
use block_to_flow::block_to_flow;
use delete_anchor::delete_unused_anchor;
use flow_to_block::{flow_map_to_block, flow_seq_to_block};
use quoted_bool::quoted_bool_to_unquoted;
use tab_to_spaces::tab_to_spaces;
use yaml11_bool::{schema_yaml11_bool_type_actions, yaml11_bool_actions};
use yaml11_octal::yaml11_octal_actions;

mod block_scalar;
mod block_to_flow;
mod delete_anchor;
mod flow_to_block;
mod quoted_bool;
mod tab_to_spaces;
mod yaml11_bool;
mod yaml11_octal;

/// Compute code actions available for the given text, range, and diagnostics.
///
/// Returns actions for:
/// - Converting flow mappings to block style (when cursor is on a `flowMap` diagnostic)
/// - Converting flow sequences to block style (when cursor is on a `flowSeq` diagnostic)
/// - Converting block mappings to flow style (when cursor is on a block mapping key)
/// - Replacing tabs with spaces (when the line contains tabs)
/// - Deleting unused anchors (when cursor is on an `unusedAnchor` diagnostic)
/// - Converting quoted booleans to unquoted (`"true"` -> `true`)
/// - Converting long strings to block scalars (`|` style)
#[must_use]
pub fn code_actions(
    docs: &[Document<Span>],
    text: &str,
    range: tower_lsp::lsp_types::Range,
    diagnostics: &[Diagnostic],
    uri: &tower_lsp::lsp_types::Url,
) -> Vec<CodeAction> {
    let lines: Vec<&str> = text.lines().collect();

    // Diagnostic-driven actions
    let diag_actions = diagnostics
        .iter()
        .filter(|diag| ranges_overlap(&diag.range, &range))
        .flat_map(|diag| match diagnostic_code(diag) {
            Some("flowMap") => flow_map_to_block(docs, text, diag, uri)
                .into_iter()
                .collect::<Vec<_>>(),
            Some("flowSeq") => flow_seq_to_block(docs, text, diag, uri)
                .into_iter()
                .collect::<Vec<_>>(),
            Some("unusedAnchor") => delete_unused_anchor(docs, text, diag, uri)
                .into_iter()
                .collect::<Vec<_>>(),
            Some("yaml11Boolean" | "schemaYaml11Boolean") => yaml11_bool_actions(docs, diag, uri),
            Some("yaml11Octal" | "schemaYaml11Octal") => yaml11_octal_actions(docs, diag, uri),
            Some("schemaYaml11BooleanType") => schema_yaml11_bool_type_actions(docs, diag, uri),
            _ => vec![],
        });

    // Context-driven actions (not tied to diagnostics)
    let line_idx = range.start.line as usize;
    let col = range.start.character as usize;
    let context_actions: Vec<CodeAction> = lines.get(line_idx).map_or(vec![], |line| {
        [
            if line.contains('\t') {
                tab_to_spaces(&lines, line_idx, uri)
            } else {
                None
            },
            quoted_bool_to_unquoted(docs, line_idx, col, uri),
            string_to_block_scalar(docs, text, line_idx, uri),
            block_to_flow(docs, line_idx, uri),
        ]
        .into_iter()
        .flatten()
        .collect()
    });

    diag_actions.chain(context_actions).collect()
}

pub(super) const fn diagnostic_code(diag: &Diagnostic) -> Option<&str> {
    match &diag.code {
        Some(NumberOrString::String(s)) => Some(s.as_str()),
        _ => None,
    }
}

const fn ranges_overlap(a: &tower_lsp::lsp_types::Range, b: &tower_lsp::lsp_types::Range) -> bool {
    a.start.line <= b.end.line && b.start.line <= a.end.line
}

#[expect(
    clippy::cast_possible_truncation,
    reason = "LSP line/col are u32; always fits"
)]
pub(super) const fn span_matches_diag(loc: &Span, diag: &Diagnostic) -> bool {
    let start_line = loc.start.line.saturating_sub(1) as u32;
    let start_col = loc.start.column as u32;
    let end_line = loc.end.line.saturating_sub(1) as u32;
    let end_col = (loc.end.column + 1) as u32;

    diag.range.start.line == start_line
        && diag.range.start.character == start_col
        && diag.range.end.line == end_line
        && diag.range.end.character == end_col
}

pub(super) fn make_action(
    title: String,
    uri: &tower_lsp::lsp_types::Url,
    edits: Vec<TextEdit>,
    kind: CodeActionKind,
    diagnostics: Option<Vec<Diagnostic>>,
) -> CodeAction {
    let mut changes = HashMap::new();
    changes.insert(uri.clone(), edits);
    CodeAction {
        title,
        kind: Some(kind),
        diagnostics,
        edit: Some(WorkspaceEdit {
            changes: Some(changes),
            ..WorkspaceEdit::default()
        }),
        ..CodeAction::default()
    }
}

#[cfg(test)]
#[expect(
    clippy::indexing_slicing,
    clippy::unwrap_used,
    clippy::expect_used,
    reason = "test helper code"
)]
mod test_helpers {
    use tower_lsp::lsp_types::{CodeAction, Diagnostic, NumberOrString, Position, Range, TextEdit};

    use rlsp_yaml_parser::Span;
    use rlsp_yaml_parser::node::Document;

    use crate::test_utils::{parse_docs, test_uri};
    use crate::validation::validators::validate_flow_style;

    use super::code_actions;

    pub(super) fn cursor_range(line: u32, col: u32) -> Range {
        Range::new(Position::new(line, col), Position::new(line, col))
    }

    pub(super) fn line_range(line: u32) -> Range {
        Range::new(Position::new(line, 0), Position::new(line, 999))
    }

    pub(super) fn make_flow_diag(
        code: &str,
        start_line: u32,
        start_char: u32,
        end_line: u32,
        end_char: u32,
    ) -> Diagnostic {
        Diagnostic {
            range: Range::new(
                Position::new(start_line, start_char),
                Position::new(end_line, end_char),
            ),
            code: Some(NumberOrString::String(code.to_string())),
            source: Some("rlsp-yaml".to_string()),
            ..Diagnostic::default()
        }
    }

    pub(super) fn make_diagnostic(line: u32, start: u32, end: u32, code: &str) -> Diagnostic {
        make_flow_diag(code, line, start, line, end)
    }

    pub(super) fn docs_for(text: &str) -> Vec<Document<Span>> {
        parse_docs(text)
    }

    pub(super) fn flow_diags_for(text: &str) -> Vec<Diagnostic> {
        let docs = docs_for(text);
        validate_flow_style(&docs)
    }

    pub(super) fn flow_map_action(text: &str) -> Option<CodeAction> {
        let docs = docs_for(text);
        let diags = flow_diags_for(text);
        let diag = diags
            .iter()
            .find(|d| d.code == Some(NumberOrString::String("flowMap".to_string())))?;
        let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
        code_actions(&docs, text, whole, std::slice::from_ref(diag), &test_uri())
            .into_iter()
            .find(|a| a.title.contains("flow mapping"))
    }

    pub(super) fn flow_seq_action(text: &str) -> Option<CodeAction> {
        let docs = docs_for(text);
        let diags = flow_diags_for(text);
        let diag = diags
            .iter()
            .find(|d| d.code == Some(NumberOrString::String("flowSeq".to_string())))?;
        let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
        code_actions(&docs, text, whole, std::slice::from_ref(diag), &test_uri())
            .into_iter()
            .find(|a| a.title.contains("flow sequence"))
    }

    pub(super) fn new_text_for(action: &CodeAction) -> String {
        action
            .edit
            .as_ref()
            .unwrap()
            .changes
            .as_ref()
            .unwrap()
            .get(&test_uri())
            .unwrap()[0]
            .new_text
            .clone()
    }

    /// Apply the first block-to-flow edit to `text` and return the resulting string.
    pub(super) fn apply_block_to_flow_edit(text: &str, line: u32) -> String {
        let actions = code_actions(
            &docs_for(text),
            text,
            cursor_range(line, 0),
            &[],
            &test_uri(),
        );
        let action = actions
            .iter()
            .find(|a| a.title.contains("block to flow"))
            .expect("expected block-to-flow action");
        let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
        let edit = &edits[0];
        let start_line = edit.range.start.line as usize;
        let start_col = edit.range.start.character as usize;
        let end_line = edit.range.end.line as usize;
        let end_col = edit.range.end.character as usize;
        let source_lines: Vec<&str> = text.lines().collect();
        let mut result = String::new();
        for (i, src_line) in source_lines.iter().enumerate() {
            if i < start_line || i > end_line {
                result.push_str(src_line);
                result.push('\n');
            } else if i == start_line && i == end_line {
                result.push_str(&src_line[..start_col]);
                result.push_str(&edit.new_text);
                result.push_str(&src_line[end_col..]);
                result.push('\n');
            } else if i == start_line {
                result.push_str(&src_line[..start_col]);
                result.push_str(&edit.new_text);
                result.push('\n');
            } else if i == end_line {
                result.push_str(&src_line[end_col..]);
                result.push('\n');
            }
            // lines strictly between start and end are absorbed into the edit
        }
        result
    }

    /// Apply the "Convert to block scalar" edit to `text` at the given line.
    /// Returns the full edited text and the raw `TextEdit` (for range assertions).
    pub(super) fn apply_block_scalar_edit(text: &str, line: u32) -> (String, TextEdit) {
        let actions = code_actions(
            &docs_for(text),
            text,
            cursor_range(line, 0),
            &[],
            &test_uri(),
        );
        let action = actions
            .iter()
            .find(|a| a.title.contains("block scalar"))
            .expect("expected block-scalar action");
        let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
        let edit = edits[0].clone();
        let source_lines: Vec<&str> = text.lines().collect();
        let line_idx = edit.range.start.line as usize;
        let start_col = edit.range.start.character as usize;
        let end_col = edit.range.end.character as usize;
        let src_line = source_lines[line_idx];
        let new_line = format!(
            "{}{}{}",
            &src_line[..start_col],
            edit.new_text,
            &src_line[end_col..]
        );
        let mut result = String::new();
        for (i, l) in source_lines.iter().enumerate() {
            if i == line_idx {
                result.push_str(&new_line);
            } else {
                result.push_str(l);
            }
            result.push('\n');
        }
        (result, edit)
    }
}