rlsp-yaml 0.10.0

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

//! Tab-to-spaces code action — operates on raw text, not the AST.
//!
//! Tabs are a pre-parse lexical concern (YAML 1.2 §6.1 forbids them for
//! indentation); the parser normalises or rejects them, so they are not
//! represented in the AST. This action is whitespace-cleanup that runs
//! before any structural editing applies — same carve-out category as
//! modeline handling and BOM stripping. Not an AST-retrofit candidate.

use tower_lsp::lsp_types::{CodeAction, CodeActionKind, Position, Range, TextEdit};

use super::make_action;

pub(super) fn tab_to_spaces(
    lines: &[&str],
    line_idx: usize,
    uri: &tower_lsp::lsp_types::Url,
) -> Option<CodeAction> {
    let line = lines.get(line_idx)?;
    if !line.contains('\t') {
        return None;
    }

    let new_text = line.replace('\t', "  ");

    #[expect(
        clippy::cast_possible_truncation,
        reason = "LSP line/col are u32; always fits"
    )]
    let edit_range = Range::new(
        Position::new(line_idx as u32, 0),
        Position::new(line_idx as u32, line.len() as u32),
    );

    Some(make_action(
        "Convert tabs to spaces".to_string(),
        uri,
        vec![TextEdit {
            range: edit_range,
            new_text,
        }],
        CodeActionKind::QUICKFIX,
        None,
    ))
}

#[cfg(test)]
#[expect(clippy::indexing_slicing, clippy::unwrap_used, reason = "test code")]
mod tests {
    use super::super::code_actions;
    use super::super::test_helpers::{cursor_range, docs_for};
    use crate::test_utils::test_uri;

    #[test]
    fn should_convert_tabs_to_spaces() {
        let text = "\tkey: value\n";
        let actions = code_actions(&docs_for(text), text, cursor_range(0, 0), &[], &test_uri());

        let action = actions
            .iter()
            .find(|a| a.title.contains("tabs to spaces"))
            .unwrap();
        let edit = action.edit.as_ref().unwrap();
        let changes = edit.changes.as_ref().unwrap();
        let edits = &changes[&test_uri()];
        assert_eq!(edits[0].new_text, "  key: value");
        assert!(!edits[0].new_text.contains('\t'));
    }

    #[test]
    fn should_not_offer_tab_conversion_without_tabs() {
        let text = "  key: value\n";
        let actions = code_actions(&docs_for(text), text, cursor_range(0, 0), &[], &test_uri());

        assert!(actions.iter().all(|a| !a.title.contains("tabs")));
    }
}