shuck-server 0.0.42

Language server scaffold for shuck
Documentation
use anyhow::Error;
use lsp_types as types;
use shuck_ast::{Command, CompoundCommand, Span, Stmt, StmtSeq, TextRange};
use shuck_formatter::FormattedSource;

use crate::edit::RangeExt;
use crate::session::{Client, DocumentSnapshot};

pub(crate) type FormatResponse = Option<Vec<types::TextEdit>>;

pub(crate) fn format_document(
    snapshot: DocumentSnapshot,
    _client: &Client,
    _params: types::DocumentFormattingParams,
) -> crate::server::Result<FormatResponse> {
    let query = snapshot.query();
    let source = query.document().contents();
    let formatted = shuck_formatter::format_source(
        source,
        query.file_path().as_deref(),
        snapshot.shuck_settings().formatter(),
    )
    .map_err(Error::new)?;

    Ok(Some(match formatted {
        FormattedSource::Unchanged => Vec::new(),
        FormattedSource::Formatted(code) => crate::edit::single_replacement_edit(
            source,
            &code,
            query.document().index(),
            snapshot.encoding(),
        )
        .into_iter()
        .collect(),
    }))
}

pub(crate) fn format_range(
    snapshot: DocumentSnapshot,
    _client: &Client,
    params: types::DocumentRangeFormattingParams,
) -> crate::server::Result<FormatResponse> {
    let Some(analysis) = snapshot.analysis() else {
        return Ok(None);
    };
    let source = analysis.source();
    let requested = params
        .range
        .to_text_range(source, analysis.line_index(), snapshot.encoding());
    let Some(statement_span) =
        smallest_statement_span_containing(&analysis.parse_result().file.body, requested)
    else {
        return Ok(None);
    };
    let statement_range = statement_span.to_range();
    let statement_source =
        &source[usize::from(statement_range.start())..usize::from(statement_range.end())];
    let formatted = shuck_formatter::format_source(
        statement_source,
        snapshot.query().file_path().as_deref(),
        snapshot.shuck_settings().formatter(),
    )
    .map_err(Error::new)?;

    Ok(Some(format_response_for_range(
        source,
        statement_range,
        formatted,
        analysis.line_index(),
        snapshot.encoding(),
    )))
}

fn format_response_for_range(
    source: &str,
    range: TextRange,
    formatted: FormattedSource,
    line_index: &shuck_indexer::LineIndex,
    encoding: crate::PositionEncoding,
) -> Vec<types::TextEdit> {
    match formatted {
        FormattedSource::Unchanged => Vec::new(),
        FormattedSource::Formatted(code) => crate::edit::single_replacement_edit_in_range(
            source, range, &code, line_index, encoding,
        )
        .into_iter()
        .collect(),
    }
}

fn smallest_statement_span_containing(body: &StmtSeq, range: TextRange) -> Option<Span> {
    let mut best = None;
    collect_statement_span(body, range, &mut best);
    best
}

fn collect_statement_span(body: &StmtSeq, range: TextRange, best: &mut Option<Span>) {
    for stmt in body.as_slice() {
        if !span_contains_text_range(stmt.span, range) {
            continue;
        }
        record_smaller_span(best, stmt.span);
        collect_nested_statement_spans(stmt, range, best);
    }
}

fn collect_nested_statement_spans(stmt: &Stmt, range: TextRange, best: &mut Option<Span>) {
    match &stmt.command {
        Command::Binary(command) => {
            collect_nested_statement_spans(&command.left, range, best);
            collect_nested_statement_spans(&command.right, range, best);
        }
        Command::Compound(command) => collect_compound_statement_spans(command, range, best),
        Command::Function(command) => collect_nested_statement_spans(&command.body, range, best),
        Command::AnonymousFunction(command) => {
            collect_nested_statement_spans(&command.body, range, best);
        }
        Command::Simple(_) | Command::Builtin(_) | Command::Decl(_) => {}
    }
}

fn collect_compound_statement_spans(
    command: &CompoundCommand,
    range: TextRange,
    best: &mut Option<Span>,
) {
    match command {
        CompoundCommand::If(command) => {
            collect_statement_span(&command.condition, range, best);
            collect_statement_span(&command.then_branch, range, best);
            for (condition, body) in &command.elif_branches {
                collect_statement_span(condition, range, best);
                collect_statement_span(body, range, best);
            }
            if let Some(body) = &command.else_branch {
                collect_statement_span(body, range, best);
            }
        }
        CompoundCommand::For(command) => collect_statement_span(&command.body, range, best),
        CompoundCommand::Repeat(command) => collect_statement_span(&command.body, range, best),
        CompoundCommand::Foreach(command) => collect_statement_span(&command.body, range, best),
        CompoundCommand::ArithmeticFor(command) => {
            collect_statement_span(&command.body, range, best)
        }
        CompoundCommand::While(command) => {
            collect_statement_span(&command.condition, range, best);
            collect_statement_span(&command.body, range, best);
        }
        CompoundCommand::Until(command) => {
            collect_statement_span(&command.condition, range, best);
            collect_statement_span(&command.body, range, best);
        }
        CompoundCommand::Case(command) => {
            for item in &command.cases {
                collect_statement_span(&item.body, range, best);
            }
        }
        CompoundCommand::Select(command) => collect_statement_span(&command.body, range, best),
        CompoundCommand::Subshell(body) | CompoundCommand::BraceGroup(body) => {
            collect_statement_span(body, range, best);
        }
        CompoundCommand::Time(command) => {
            if let Some(command) = &command.command {
                collect_nested_statement_spans(command, range, best);
            }
        }
        CompoundCommand::Coproc(command) => {
            collect_nested_statement_spans(&command.body, range, best);
        }
        CompoundCommand::Always(command) => {
            collect_statement_span(&command.body, range, best);
            collect_statement_span(&command.always_body, range, best);
        }
        CompoundCommand::Arithmetic(_) | CompoundCommand::Conditional(_) => {}
    }
}

fn record_smaller_span(best: &mut Option<Span>, candidate: Span) {
    if best.is_none_or(|current| span_width(candidate) < span_width(current)) {
        *best = Some(candidate);
    }
}

fn span_width(span: Span) -> usize {
    span.end.offset.saturating_sub(span.start.offset)
}

fn span_contains_text_range(span: Span, range: TextRange) -> bool {
    span.start.offset <= usize::from(range.start()) && usize::from(range.end()) <= span.end.offset
}

#[cfg(test)]
mod tests {
    use crossbeam::channel;
    use lsp_types::{ClientCapabilities, PositionEncodingKind, Url};

    use super::*;
    use crate::{
        Client, GlobalOptions, PositionEncoding, Session, TextDocument, Workspace, Workspaces,
    };

    #[test]
    fn document_formatting_uses_shuck_formatter() {
        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
        let (client_sender, _client_receiver) = channel::unbounded();
        let client = Client::new(main_loop_sender, client_sender);
        let workspace_root = std::env::temp_dir().join("shuck-server-format-document-tests");
        let workspace_uri =
            Url::from_file_path(&workspace_root).expect("workspace path should convert to a URL");
        let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
        let global = GlobalOptions::default().into_settings(client.clone());
        let mut session = Session::new(
            &ClientCapabilities::default(),
            PositionEncoding::UTF16,
            global,
            &workspaces,
            &client,
        )
        .expect("test session should initialize");

        let uri = Url::from_file_path(workspace_root.join("script.sh"))
            .expect("script path should convert to a URL");
        session.open_text_document(
            uri.clone(),
            TextDocument::new("if true; then\necho ok\nfi\n".to_owned(), 1)
                .with_language_id("shellscript"),
        );
        let snapshot = session
            .take_snapshot(uri.clone())
            .expect("test document should produce a snapshot");

        let edits = format_document(
            snapshot,
            &client,
            types::DocumentFormattingParams {
                text_document: types::TextDocumentIdentifier { uri },
                options: types::FormattingOptions::default(),
                work_done_progress_params: types::WorkDoneProgressParams::default(),
            },
        )
        .expect("document formatting should succeed")
        .expect("document formatting should return an edit list");

        assert_eq!(edits.len(), 1);
        assert_eq!(edits[0].range.start, types::Position::new(1, 0));
        assert_eq!(edits[0].range.end, types::Position::new(1, 0));
        assert_eq!(edits[0].new_text, "\t");
    }

    #[test]
    fn range_formatting_returns_empty_edits_for_already_formatted_buffer() {
        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
        let (client_sender, _client_receiver) = channel::unbounded();
        let client = Client::new(main_loop_sender, client_sender);
        let workspace_root = std::env::temp_dir().join("shuck-server-format-tests");
        let workspace_uri =
            Url::from_file_path(&workspace_root).expect("workspace path should convert to a URL");
        let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
        let global = GlobalOptions::default().into_settings(client.clone());
        let mut session = Session::new(
            &ClientCapabilities {
                general: Some(lsp_types::GeneralClientCapabilities {
                    position_encodings: Some(vec![PositionEncodingKind::UTF16]),
                    ..Default::default()
                }),
                ..Default::default()
            },
            PositionEncoding::UTF16,
            global,
            &workspaces,
            &client,
        )
        .expect("test session should initialize");

        let uri = Url::from_file_path(workspace_root.join("script.sh"))
            .expect("script path should convert to a URL");
        session.open_text_document(
            uri.clone(),
            TextDocument::new("echo hi\n".to_owned(), 1).with_language_id("shellscript"),
        );
        let snapshot = session
            .take_snapshot(uri.clone())
            .expect("test document should produce a snapshot");

        let edits = format_range(
            snapshot,
            &client,
            types::DocumentRangeFormattingParams {
                text_document: types::TextDocumentIdentifier { uri },
                range: types::Range::new(types::Position::new(0, 0), types::Position::new(0, 7)),
                options: types::FormattingOptions::default(),
                work_done_progress_params: types::WorkDoneProgressParams::default(),
            },
        )
        .expect("range formatting should succeed")
        .expect("range formatting should return an edit list");

        assert!(edits.is_empty());
    }

    #[test]
    fn range_formatting_returns_none_for_ranges_without_one_complete_statement() {
        let (main_loop_sender, _main_loop_receiver) = channel::unbounded();
        let (client_sender, _client_receiver) = channel::unbounded();
        let client = Client::new(main_loop_sender, client_sender);
        let workspace_root = std::env::temp_dir().join("shuck-server-format-tests-partial");
        let workspace_uri =
            Url::from_file_path(&workspace_root).expect("workspace path should convert to a URL");
        let workspaces = Workspaces::new(vec![Workspace::default(workspace_uri)]);
        let global = GlobalOptions::default().into_settings(client.clone());
        let mut session = Session::new(
            &ClientCapabilities::default(),
            PositionEncoding::UTF16,
            global,
            &workspaces,
            &client,
        )
        .expect("test session should initialize");

        let uri = Url::from_file_path(workspace_root.join("script.sh"))
            .expect("script path should convert to a URL");
        session.open_text_document(
            uri.clone(),
            TextDocument::new("echo one\necho two\n".to_owned(), 1).with_language_id("shellscript"),
        );
        let snapshot = session
            .take_snapshot(uri.clone())
            .expect("test document should produce a snapshot");

        let edits = format_range(
            snapshot,
            &client,
            types::DocumentRangeFormattingParams {
                text_document: types::TextDocumentIdentifier { uri },
                range: types::Range::new(types::Position::new(0, 2), types::Position::new(1, 2)),
                options: types::FormattingOptions::default(),
                work_done_progress_params: types::WorkDoneProgressParams::default(),
            },
        )
        .expect("range formatting should succeed");

        assert!(edits.is_none());
    }

    #[test]
    fn range_formatting_edit_helper_does_not_escape_statement_range() {
        let source = "echo one\necho two\n";
        let index = shuck_indexer::LineIndex::new(source);
        let edits = format_response_for_range(
            source,
            TextRange::new(shuck_ast::TextSize::new(9), shuck_ast::TextSize::new(18)),
            FormattedSource::Formatted("printf two\n".to_owned()),
            &index,
            PositionEncoding::UTF16,
        );

        assert_eq!(edits.len(), 1);
        assert_eq!(edits[0].range.start, types::Position::new(1, 0));
        assert_eq!(edits[0].range.end, types::Position::new(1, 4));
        assert_eq!(edits[0].new_text, "printf");
    }
}