shuck-server 0.0.43

Language server scaffold for shuck
Documentation
use std::thread;
use std::time::Duration;

use lsp_server::{Connection, Message, Notification, Request, RequestId};
use lsp_types::{
    ClientCapabilities, CodeAction, CodeActionContext, CodeActionParams, DocumentDiagnosticParams,
    DocumentDiagnosticReport, DocumentDiagnosticReportResult, HoverParams, PartialResultParams,
    Position, Range, TextDocumentIdentifier, TextDocumentPositionParams, Url,
    WorkDoneProgressParams,
};

fn send_request(connection: &Connection, id: i32, method: &str, params: serde_json::Value) {
    connection
        .sender
        .send(Message::Request(Request {
            id: RequestId::from(id),
            method: method.to_owned(),
            params,
        }))
        .expect("request should send");
}

fn recv_response(connection: &Connection, id: i32) -> serde_json::Value {
    loop {
        let message = connection
            .receiver
            .recv_timeout(Duration::from_secs(2))
            .expect("server should respond");
        match message {
            Message::Response(response) if response.id == RequestId::from(id) => {
                assert!(
                    response.error.is_none(),
                    "unexpected LSP error: {:?}",
                    response.error
                );
                return response
                    .result
                    .expect("successful response should carry a result");
            }
            Message::Notification(_) => continue,
            Message::Request(request) => panic!(
                "unexpected server request during replay: {}",
                request.method
            ),
            Message::Response(_) => continue,
        }
    }
}

fn replay_capabilities() -> ClientCapabilities {
    serde_json::from_value(serde_json::json!({
        "general": {
            "positionEncodings": ["utf-16"]
        },
        "textDocument": {
            "diagnostic": {
                "dynamicRegistration": false,
                "relatedDocumentSupport": false
            },
            "codeAction": {
                "dataSupport": true,
                "resolveSupport": { "properties": ["edit"] }
            },
            "hover": {
                "contentFormat": ["markdown"]
            }
        },
        "workspace": {
            "applyEdit": true,
            "workspaceEdit": {
                "documentChanges": true
            },
            "workspaceFolders": true,
            "configuration": false
        }
    }))
    .expect("test client capabilities should deserialize")
}

#[test]
fn replays_a_small_lsp_session() {
    let (server_connection, client_connection) = Connection::memory();
    let server_thread = thread::spawn(move || shuck_server::run_connection(server_connection));

    let workspace_root = tempfile::tempdir().expect("tempdir should be created");
    let script_path = workspace_root.path().join("script.sh");
    let script_uri =
        Url::from_file_path(&script_path).expect("script path should convert to a URL");

    send_request(
        &client_connection,
        1,
        "initialize",
        serde_json::json!({
            "capabilities": replay_capabilities(),
            "rootUri": Url::from_file_path(workspace_root.path())
                .expect("workspace path should convert to a URL"),
            "initializationOptions": { "shuck": { "fixAll": true, "unsafeFixes": true } }
        }),
    );
    let initialize = recv_response(&client_connection, 1);
    assert_eq!(
        initialize["capabilities"]["documentFormattingProvider"],
        serde_json::json!(true)
    );
    assert_eq!(
        initialize["capabilities"]["documentRangeFormattingProvider"],
        serde_json::json!(true)
    );
    assert_eq!(
        initialize["capabilities"]["definitionProvider"],
        serde_json::json!(true)
    );
    assert_eq!(
        initialize["capabilities"]["referencesProvider"],
        serde_json::json!(true)
    );
    assert_eq!(
        initialize["capabilities"]["documentHighlightProvider"],
        serde_json::json!(true)
    );
    assert!(initialize["capabilities"]["completionProvider"].is_object());
    assert_eq!(
        initialize["capabilities"]["renameProvider"]["prepareProvider"],
        serde_json::json!(true)
    );

    client_connection
        .sender
        .send(Message::Notification(Notification::new(
            "initialized".to_owned(),
            serde_json::json!({}),
        )))
        .expect("initialized notification should send");

    client_connection
        .sender
        .send(Message::Notification(Notification::new(
            "textDocument/didOpen".to_owned(),
            serde_json::json!({
                "textDocument": {
                    "uri": script_uri,
                    "languageId": "shellscript",
                    "version": 1,
                    "text": "foo=1\n",
                }
            }),
        )))
        .expect("didOpen notification should send");

    send_request(
        &client_connection,
        2,
        "textDocument/diagnostic",
        serde_json::to_value(DocumentDiagnosticParams {
            text_document: TextDocumentIdentifier {
                uri: script_uri.clone(),
            },
            identifier: None,
            previous_result_id: None,
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        })
        .expect("diagnostic params should serialize"),
    );
    let diagnostic_report: DocumentDiagnosticReportResult =
        serde_json::from_value(recv_response(&client_connection, 2))
            .expect("diagnostic response should deserialize");
    let DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(report)) =
        diagnostic_report
    else {
        panic!("expected a full diagnostic report");
    };
    assert_eq!(report.full_document_diagnostic_report.items.len(), 1);
    let diagnostics = report.full_document_diagnostic_report.items;

    send_request(
        &client_connection,
        3,
        "textDocument/codeAction",
        serde_json::to_value(CodeActionParams {
            text_document: TextDocumentIdentifier {
                uri: script_uri.clone(),
            },
            range: Range::new(Position::new(0, 0), Position::new(0, 3)),
            context: CodeActionContext {
                diagnostics: diagnostics.clone(),
                only: None,
                trigger_kind: None,
            },
            work_done_progress_params: WorkDoneProgressParams::default(),
            partial_result_params: PartialResultParams::default(),
        })
        .expect("code action params should serialize"),
    );
    let actions: Vec<lsp_types::CodeActionOrCommand> =
        serde_json::from_value(recv_response(&client_connection, 3))
            .expect("code action response should deserialize");
    let fix_all = actions
        .into_iter()
        .filter_map(|entry| match entry {
            lsp_types::CodeActionOrCommand::CodeAction(action) => Some(action),
            lsp_types::CodeActionOrCommand::Command(_) => None,
        })
        .find(|action| {
            action
                .kind
                .as_ref()
                .is_some_and(|kind| kind.as_str() == "source.fixAll.shuck")
        })
        .expect("fix-all action should be present");
    assert!(fix_all.edit.is_none());
    assert!(fix_all.data.is_some());

    send_request(
        &client_connection,
        4,
        "codeAction/resolve",
        serde_json::to_value(fix_all).expect("code action should serialize"),
    );
    let resolved: CodeAction = serde_json::from_value(recv_response(&client_connection, 4))
        .expect("resolved code action should deserialize");
    assert!(resolved.edit.is_some());

    client_connection
        .sender
        .send(Message::Notification(Notification::new(
            "textDocument/didChange".to_owned(),
            serde_json::json!({
                "textDocument": { "uri": script_uri, "version": 2 },
                "contentChanges": [{ "text": "#!/bin/bash\necho $foo  # shellcheck disable=SC2154\n" }],
            }),
        )))
        .expect("didChange notification should send");

    send_request(
        &client_connection,
        5,
        "textDocument/hover",
        serde_json::to_value(HoverParams {
            text_document_position_params: TextDocumentPositionParams {
                text_document: TextDocumentIdentifier {
                    uri: script_uri.clone(),
                },
                position: Position::new(1, 37),
            },
            work_done_progress_params: WorkDoneProgressParams::default(),
        })
        .expect("hover params should serialize"),
    );
    let hover = recv_response(&client_connection, 5);
    assert!(
        hover["contents"]["value"]
            .as_str()
            .is_some_and(|value| value.contains("Undefined Variable"))
    );

    send_request(&client_connection, 99, "shutdown", serde_json::json!(null));
    let _ = recv_response(&client_connection, 99);
    client_connection
        .sender
        .send(Message::Notification(Notification::new(
            "exit".to_owned(),
            serde_json::json!({}),
        )))
        .expect("exit notification should send");

    server_thread
        .join()
        .expect("server thread should join")
        .expect("server should exit cleanly");
}