emmylua_ls 0.23.2

A language server for emmylua.
Documentation
mod external_range_format;

use emmylua_formatter::reformat_range_in_chunk;
use lsp_types::{
    ClientCapabilities, DocumentRangeFormattingParams, OneOf, Position, Range, ServerCapabilities,
    TextEdit,
};
use tokio_util::sync::CancellationToken;

use crate::{
    context::ServerContextSnapshot,
    handlers::{
        document_formatting::{FormattingOptions, build_workspace_formatter_config},
        document_range_formatting::external_range_format::external_tool_range_format,
    },
};

use super::RegisterCapabilities;

pub struct RangeFormatResult {
    pub text: String,
    pub start_line: i32,
    pub start_col: i32,
    pub end_line: i32,
    pub end_col: i32,
}

pub async fn on_range_formatting_handler(
    context: ServerContextSnapshot,
    params: DocumentRangeFormattingParams,
    _: CancellationToken,
) -> Option<Vec<TextEdit>> {
    let uri = params.text_document.uri;
    let request_range = params.range;
    let analysis = context.analysis().read().await;
    let workspace_manager = context.workspace_manager().read().await;
    let client_id = workspace_manager.client_config.client_id;
    let file_id = analysis.get_file_id(&uri)?;
    let emmyrc = analysis.get_emmyrc();
    let document = analysis
        .compilation
        .get_db()
        .get_vfs()
        .get_document(&file_id)?;
    let file_path = document.get_file_path();
    let normalized_path = file_path.to_string_lossy().to_string().replace("\\", "/");
    let formatting_options = FormattingOptions {
        indent_size: params.options.tab_size,
        use_tabs: !params.options.insert_spaces,
        insert_final_newline: params.options.insert_final_newline.unwrap_or(true),
        non_standard_symbol: !emmyrc.runtime.nonstandard_symbol.is_empty(),
    };
    let formatted_result = if let Some(external_tool) = &emmyrc.format.external_tool_range_format {
        external_tool_range_format(
            external_tool,
            &document,
            &request_range,
            &normalized_path,
            formatting_options,
        )
        .await?
    } else {
        let syntax_tree = analysis
            .compilation
            .get_db()
            .get_vfs()
            .get_syntax_tree(&file_id)?;
        let chunk = syntax_tree.get_chunk_node();
        let config = build_workspace_formatter_config(
            Some(file_path.as_path()),
            params.options.tab_size as usize,
            params.options.insert_spaces,
            params.options.insert_final_newline.unwrap_or(true),
        );
        let selection = document.to_rowan_range(request_range)?;
        let output = reformat_range_in_chunk(
            document.get_text(),
            &chunk,
            selection,
            &config,
            emmyrc.get_language_level(),
        )?;
        let mut new_text = output.text;
        if client_id.is_intellij() || client_id.is_other() {
            new_text = new_text.replace("\r\n", "\n");
        }

        return Some(vec![TextEdit {
            range: document.to_lsp_range(output.replace_range)?,
            new_text,
        }]);
    };

    let mut formatted_text = formatted_result.text;
    if client_id.is_intellij() || client_id.is_other() {
        formatted_text = formatted_text.replace("\r\n", "\n");
    }

    let text_edit = TextEdit {
        range: Range {
            start: Position {
                line: formatted_result.start_line as u32,
                character: formatted_result.start_col as u32,
            },
            end: Position {
                line: formatted_result.end_line as u32,
                character: formatted_result.end_col as u32,
            },
        },
        new_text: formatted_text,
    };

    Some(vec![text_edit])
}

pub struct DocumentRangeFormattingCapabilities;

impl RegisterCapabilities for DocumentRangeFormattingCapabilities {
    fn register_capabilities(server_capabilities: &mut ServerCapabilities, _: &ClientCapabilities) {
        server_capabilities.document_range_formatting_provider = Some(OneOf::Left(true));
    }
}

#[cfg(test)]
mod tests {
    use std::cmp::Reverse;

    use emmylua_code_analysis::{Emmyrc, Vfs, VirtualUrlGenerator};
    use emmylua_formatter::reformat_range_in_chunk;
    use emmylua_parser::{LuaLanguageLevel, LuaParser, ParserConfig};
    use lsp_types::{Position, Range, TextEdit};

    use crate::handlers::document_formatting::build_workspace_formatter_config;

    fn create_document<'a>(vfs: &'a mut Vfs, text: &str) -> emmylua_code_analysis::LuaDocument<'a> {
        vfs.update_config(Emmyrc::default().into());
        let vg = VirtualUrlGenerator::new();
        let uri = vg.new_uri("range.lua");
        let id = vfs.set_file_content(&uri, Some(text.to_string()));
        vfs.get_document(&id).unwrap()
    }

    fn build_range_edit(
        document: &emmylua_code_analysis::LuaDocument<'_>,
        request_range: Range,
    ) -> Option<TextEdit> {
        let config = build_workspace_formatter_config(
            Some(document.get_file_path().as_path()),
            4,
            true,
            true,
        );
        let tree = LuaParser::parse(
            document.get_text(),
            ParserConfig::with_level(LuaLanguageLevel::Lua55),
        );
        let output = reformat_range_in_chunk(
            document.get_text(),
            &tree.get_chunk_node(),
            document.to_rowan_range(request_range)?,
            &config,
            LuaLanguageLevel::Lua55,
        )?;

        Some(TextEdit {
            range: document.to_lsp_range(output.replace_range)?,
            new_text: output.text,
        })
    }

    #[test]
    fn range_edit_expands_only_to_selected_statement_lines() {
        let source = "local a=1\nlocal b=2\n";
        let mut vfs = Vfs::new();
        let document = create_document(&mut vfs, source);
        let first_line_len = source
            .lines()
            .next()
            .expect("first line should exist")
            .len() as u32;

        let edit = build_range_edit(
            &document,
            Range {
                start: Position {
                    line: 0,
                    character: 0,
                },
                end: Position {
                    line: 0,
                    character: first_line_len,
                },
            },
        )
        .expect("range format should succeed");

        assert_eq!(edit.range.start.line, 0);
        assert_eq!(edit.range.end.line, 1);
        assert_eq!(edit.new_text, "local a = 1\n");
    }

    fn apply_text_edits(
        document: &emmylua_code_analysis::LuaDocument<'_>,
        source: &str,
        edits: &[TextEdit],
    ) -> String {
        let mut applied = source.to_string();
        let mut ranges: Vec<_> = edits
            .iter()
            .map(|edit| {
                let start = document
                    .get_offset(
                        edit.range.start.line as usize,
                        edit.range.start.character as usize,
                    )
                    .expect("edit start offset should exist");
                let end = document
                    .get_offset(
                        edit.range.end.line as usize,
                        edit.range.end.character as usize,
                    )
                    .expect("edit end offset should exist");
                (start, end, edit.new_text.as_str())
            })
            .collect();
        ranges.sort_by_key(|right| Reverse(right.0));

        for (start, end, new_text) in ranges {
            applied.replace_range(usize::from(start)..usize::from(end), new_text);
        }

        applied
    }

    #[test]
    fn range_edit_keeps_full_multiline_expansion_when_selecting_block() {
        let source = "local function func1()\n    local a = { { a = 1, aa = 2, aaa = 3, aaaa = 4, aaaaa = 5, aaaaaa = 6, aaaaaaa = 7, aaaaaaaaa = 8, aaaaaaaaaa = 9, aaaaaaaaaaa = 10 } }\n    local b\nend\n";
        let mut vfs = Vfs::new();
        let document = create_document(&mut vfs, source);
        let range = Range {
            start: Position {
                line: 0,
                character: 0,
            },
            end: Position {
                line: 3,
                character: 3,
            },
        };

        let edit = build_range_edit(&document, range).expect("range format should succeed");
        let config = build_workspace_formatter_config(
            Some(document.get_file_path().as_path()),
            4,
            true,
            true,
        );
        let tree = LuaParser::parse(source, ParserConfig::with_level(LuaLanguageLevel::Lua55));
        let formatted = reformat_range_in_chunk(
            source,
            &tree.get_chunk_node(),
            document
                .to_rowan_range(range)
                .expect("full document range should convert"),
            &config,
            LuaLanguageLevel::Lua55,
        )
        .expect("formatter range output should exist")
        .text;
        let edits = vec![edit];
        let applied = apply_text_edits(&document, source, &edits);

        assert_eq!(applied, formatted);
        assert!(applied.contains("            aaaaaaaaaaa = 10"));
        assert!(applied.contains("    local b"));
    }
}