From cef20b8bdc8b2c20224bf290d6267ce5a9f2f2d5 Mon Sep 17 00:00:00 2001 From: PolyMeilex Date: Thu, 17 Apr 2025 20:05:36 +0200 Subject: [PATCH 1/8] feat: Stylua LSP server --- Cargo.lock | 87 +++++++++++++++++++++++++++++++------- Cargo.toml | 4 ++ src/cli/lsp.rs | 108 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cli/main.rs | 6 +++ src/cli/opt.rs | 4 ++ 5 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 src/cli/lsp.rs diff --git a/Cargo.lock b/Cargo.lock index 7c5ee35c..6f0e2080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -310,7 +310,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.100", "unicode-xid", ] @@ -375,6 +375,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "full_moon" version = "2.0.0" @@ -574,6 +583,42 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "lsp-server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9462c4dc73e17f971ec1f171d44bfffb72e65a130117233388a0ebc7ec5656f9" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "lsp-textdocument" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d564d595f4e3dcd3c071bf472dbd2cac53bc3665ae7222d2abfecd18feaed2c" +dependencies = [ + "lsp-types", + "serde_json", +] + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "memchr" version = "2.7.1" @@ -704,9 +749,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] @@ -814,22 +859,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.196" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.100", ] [[package]] @@ -843,6 +888,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "serde_spanned" version = "0.6.5" @@ -896,7 +952,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.48", + "syn 2.0.100", ] [[package]] @@ -919,6 +975,9 @@ dependencies = [ "insta", "lazy_static", "log", + "lsp-server", + "lsp-textdocument", + "lsp-types", "num_cpus", "regex", "serde", @@ -944,9 +1003,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", @@ -1004,7 +1063,7 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.100", ] [[package]] @@ -1124,7 +1183,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.100", "wasm-bindgen-shared", ] @@ -1146,7 +1205,7 @@ checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.100", "wasm-bindgen-backend", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index a5655604..ceb1ae29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,10 @@ thiserror = "1.0.49" threadpool = "1.8.1" toml = "0.8.1" +lsp-server = "0.7" +lsp-types = "0.97" +lsp-textdocument = "0.4.2" + [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { version = "0.2.81", optional = true } diff --git a/src/cli/lsp.rs b/src/cli/lsp.rs new file mode 100644 index 00000000..4e611da7 --- /dev/null +++ b/src/cli/lsp.rs @@ -0,0 +1,108 @@ +use lsp_server::{Connection, Message, RequestId, Response}; +use lsp_textdocument::TextDocuments; +use lsp_types::{ + request::{Formatting, RangeFormatting, Request}, + DocumentFormattingParams, OneOf, Position, Range, ServerCapabilities, + TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, +}; + +use crate::{config::ConfigResolver, opt}; + +pub fn run(opt: opt::Opt) -> anyhow::Result<()> { + // Load the configuration + let opt_for_config_resolver = opt.clone(); + let mut config_resolver = ConfigResolver::new(&opt_for_config_resolver)?; + + let (connection, io_threads) = Connection::stdio(); + + let capabilities = ServerCapabilities { + document_formatting_provider: Some(OneOf::Left(true)), + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::INCREMENTAL, + )), + ..Default::default() + }; + + connection.initialize(serde_json::to_value(capabilities)?)?; + + let mut documents = TextDocuments::new(); + + for msg in &connection.receiver { + match msg { + Message::Request(req) => { + if connection.handle_shutdown(&req)? { + break; + } + + match req.method.as_str() { + Formatting::METHOD => { + let result = + match serde_json::from_value::(req.params) { + Ok(params) => { + let res = handle_formatting( + req.id.clone(), + params, + &mut config_resolver, + &documents, + ); + + res.unwrap_or(Response::new_ok(req.id, serde_json::Value::Null)) + } + Err(err) => Response::new_err(req.id, 1, err.to_string()), + }; + + connection.sender.send(Message::Response(result))?; + } + RangeFormatting::METHOD => { + // TODO: Would be cool to support this in the future + } + _ => {} + } + } + Message::Response(_) => {} + Message::Notification(notification) => { + documents.listen(notification.method.as_str(), ¬ification.params); + } + } + } + + io_threads.join()?; + + Ok(()) +} + +fn handle_formatting( + id: RequestId, + params: DocumentFormattingParams, + config_resolver: &mut ConfigResolver, + documents: &TextDocuments, +) -> Option { + let uri = params.text_document.uri; + let path = uri.path().as_str(); + + let src = documents.get_document_content(&uri, None)?; + + let config = config_resolver + .load_configuration(path.as_ref()) + .unwrap_or_default(); + + let new_text = + stylua_lib::format_code(src, config, None, stylua_lib::OutputVerification::None).ok()?; + + if new_text == src { + return None; + } + + // TODO: We can be smarter about this in the future, and update only the parts that changed + let edit = TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(u32::MAX, 0), + }, + new_text, + }; + + let edits: Vec = vec![edit]; + + Some(Response::new_ok(id, edits)) +} diff --git a/src/cli/main.rs b/src/cli/main.rs index 16eead5c..857ad985 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -19,6 +19,7 @@ use stylua_lib::{format_code, Config, OutputVerification, Range}; use crate::config::find_ignore_file_path; mod config; +mod lsp; mod opt; mod output_diff; @@ -265,6 +266,11 @@ fn path_is_stylua_ignored(path: &Path, search_parent_directories: bool) -> Resul fn format(opt: opt::Opt) -> Result { debug!("resolved options: {:#?}", opt); + if opt.lsp { + lsp::run(opt)?; + return Ok(0); + } + if opt.files.is_empty() { bail!("no files provided"); } diff --git a/src/cli/opt.rs b/src/cli/opt.rs index e38ef3a3..a23a19a5 100644 --- a/src/cli/opt.rs +++ b/src/cli/opt.rs @@ -108,6 +108,10 @@ pub struct Opt { /// Respect .styluaignore and glob matching for file paths provided directly to the tool #[structopt(long)] pub respect_ignores: bool, + + /// Run Stylua LSP server + #[structopt(long)] + pub lsp: bool, } #[derive(ArgEnum, Clone, Copy, Debug, PartialEq, Eq)] From 3df934a5b318685297ac60308e0d11923ac0d58b Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 17:02:13 +0200 Subject: [PATCH 2/8] Reorganise, handle range formatting --- src/cli/lsp.rs | 186 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 122 insertions(+), 64 deletions(-) diff --git a/src/cli/lsp.rs b/src/cli/lsp.rs index 4e611da7..9ab611e7 100644 --- a/src/cli/lsp.rs +++ b/src/cli/lsp.rs @@ -1,13 +1,129 @@ -use lsp_server::{Connection, Message, RequestId, Response}; -use lsp_textdocument::TextDocuments; +use std::convert::TryInto; + +use lsp_server::{Connection, ErrorCode, Message, Response}; +use lsp_textdocument::{FullTextDocument, TextDocuments}; use lsp_types::{ request::{Formatting, RangeFormatting, Request}, - DocumentFormattingParams, OneOf, Position, Range, ServerCapabilities, - TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, + DocumentFormattingParams, DocumentRangeFormattingParams, OneOf, Position, Range, + ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Uri, }; +use stylua_lib::{format_code, OutputVerification}; use crate::{config::ConfigResolver, opt}; +fn handle_formatting( + uri: &Uri, + document: &FullTextDocument, + range: Option, + config_resolver: &mut ConfigResolver, +) -> Option> { + if document.language_id() != "lua" && document.language_id() != "luau" { + return None; + } + + let contents = document.get_content(None); + + let config = config_resolver + .load_configuration(uri.path().as_str().as_ref()) + .unwrap_or_default(); + + let formatted_contents = format_code(contents, config, range, OutputVerification::None).ok()?; + + let last_line_idx = document.line_count().saturating_sub(1); + let last_line_offset = document.offset_at(Position::new(last_line_idx, 0)); + let last_col = document.content_len() - last_line_offset; + + // TODO: We can be smarter about this in the future, and update only the parts that changed (using output_diff) + Some(vec![TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(last_line_idx, last_col), + }, + new_text: formatted_contents, + }]) +} + +fn handle_request( + request: lsp_server::Request, + documents: &TextDocuments, + config_resolver: &mut ConfigResolver, +) -> Response { + match request.method.as_str() { + Formatting::METHOD => { + match serde_json::from_value::(request.params) { + Ok(params) => { + let Some(document) = documents.get_document(¶ms.text_document.uri) else { + return Response::new_err( + request.id, + ErrorCode::RequestFailed as i32, + format!( + "no document found for '{}'", + params.text_document.uri.as_str() + ), + ); + }; + + match handle_formatting( + ¶ms.text_document.uri, + document, + None, + config_resolver, + ) { + Some(edits) => Response::new_ok(request.id, edits), + None => Response::new_ok(request.id, serde_json::Value::Null), + } + } + Err(err) => Response::new_err( + request.id, + lsp_server::ErrorCode::RequestFailed as i32, + err.to_string(), + ), + } + } + RangeFormatting::METHOD => { + match serde_json::from_value::(request.params) { + Ok(params) => { + let Some(document) = documents.get_document(¶ms.text_document.uri) else { + return Response::new_err( + request.id, + 1, + format!( + "no document found for '{}'", + params.text_document.uri.as_str() + ), + ); + }; + + let range = stylua_lib::Range::from_values( + Some(document.offset_at(params.range.start).try_into().unwrap()), + Some(document.offset_at(params.range.end).try_into().unwrap()), + ); + + match handle_formatting( + ¶ms.text_document.uri, + document, + Some(range), + config_resolver, + ) { + Some(edits) => Response::new_ok(request.id, edits), + None => Response::new_ok(request.id, serde_json::Value::Null), + } + } + Err(err) => Response::new_err( + request.id, + lsp_server::ErrorCode::RequestFailed as i32, + err.to_string(), + ), + } + } + _ => Response::new_err( + request.id, + lsp_server::ErrorCode::MethodNotFound as i32, + format!("server does not support method '{}'", request.method), + ), + } +} + pub fn run(opt: opt::Opt) -> anyhow::Result<()> { // Load the configuration let opt_for_config_resolver = opt.clone(); @@ -34,30 +150,8 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { break; } - match req.method.as_str() { - Formatting::METHOD => { - let result = - match serde_json::from_value::(req.params) { - Ok(params) => { - let res = handle_formatting( - req.id.clone(), - params, - &mut config_resolver, - &documents, - ); - - res.unwrap_or(Response::new_ok(req.id, serde_json::Value::Null)) - } - Err(err) => Response::new_err(req.id, 1, err.to_string()), - }; - - connection.sender.send(Message::Response(result))?; - } - RangeFormatting::METHOD => { - // TODO: Would be cool to support this in the future - } - _ => {} - } + let response = handle_request(req, &documents, &mut config_resolver); + connection.sender.send(Message::Response(response))? } Message::Response(_) => {} Message::Notification(notification) => { @@ -70,39 +164,3 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { Ok(()) } - -fn handle_formatting( - id: RequestId, - params: DocumentFormattingParams, - config_resolver: &mut ConfigResolver, - documents: &TextDocuments, -) -> Option { - let uri = params.text_document.uri; - let path = uri.path().as_str(); - - let src = documents.get_document_content(&uri, None)?; - - let config = config_resolver - .load_configuration(path.as_ref()) - .unwrap_or_default(); - - let new_text = - stylua_lib::format_code(src, config, None, stylua_lib::OutputVerification::None).ok()?; - - if new_text == src { - return None; - } - - // TODO: We can be smarter about this in the future, and update only the parts that changed - let edit = TextEdit { - range: Range { - start: Position::new(0, 0), - end: Position::new(u32::MAX, 0), - }, - new_text, - }; - - let edits: Vec = vec![edit]; - - Some(Response::new_ok(id, edits)) -} From e21111234bbb98d6d206b3202510f79867a131a4 Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 17:02:22 +0200 Subject: [PATCH 3/8] Add integration tests --- src/cli/lsp.rs | 5 +- tests/test_lsp_integration.rs | 297 ++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 tests/test_lsp_integration.rs diff --git a/src/cli/lsp.rs b/src/cli/lsp.rs index 9ab611e7..374af648 100644 --- a/src/cli/lsp.rs +++ b/src/cli/lsp.rs @@ -129,7 +129,7 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { let opt_for_config_resolver = opt.clone(); let mut config_resolver = ConfigResolver::new(&opt_for_config_resolver)?; - let (connection, io_threads) = Connection::stdio(); + let (connection, _io_threads) = Connection::stdio(); let capabilities = ServerCapabilities { document_formatting_provider: Some(OneOf::Left(true)), @@ -160,7 +160,8 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { } } - io_threads.join()?; + // HACK: to get our integration tests working and not hanging. We should probably join... + // io_threads.join()?; Ok(()) } diff --git a/tests/test_lsp_integration.rs b/tests/test_lsp_integration.rs new file mode 100644 index 00000000..138aaad8 --- /dev/null +++ b/tests/test_lsp_integration.rs @@ -0,0 +1,297 @@ +use std::str::FromStr; + +use assert_cmd::Command; + +use lsp_server::{ErrorCode, Message, Notification, Request, RequestId, Response}; +use lsp_types::{ + notification::{DidOpenTextDocument, Exit, Initialized, Notification as NotificationType}, + request::{Formatting, Initialize, RangeFormatting, Request as RequestType, Shutdown}, + DidOpenTextDocumentParams, DocumentFormattingParams, DocumentRangeFormattingParams, + FormattingOptions, InitializeParams, OneOf, Position, Range, ServerCapabilities, + TextDocumentIdentifier, TextDocumentItem, TextDocumentSyncCapability, TextDocumentSyncKind, + TextEdit, Uri, WorkDoneProgressParams, +}; +use serde_json::to_value; + +fn create_stylua() -> Command { + Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() +} + +fn join_messages(input: Vec) -> Vec { + let mut buffer = Vec::new(); + input + .iter() + .for_each(|message| message.clone().write(&mut buffer).unwrap()); + buffer +} + +fn initialize(id: i32) -> Message { + Message::Request(Request { + id: RequestId::from(id), + method: ::METHOD.to_string(), + params: to_value(InitializeParams::default()).unwrap(), + }) +} + +fn server_initialized(id: i32) -> Message { + Message::Response(Response::new_ok( + RequestId::from(id), + serde_json::json!({ + "capabilities": ServerCapabilities { + document_formatting_provider: Some(OneOf::Left(true)), + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::INCREMENTAL, + )), + ..Default::default() + }}), + )) +} + +fn initialized() -> Message { + Message::Notification(Notification { + method: Initialized::METHOD.to_string(), + params: serde_json::Value::Null, + }) +} + +fn shutdown(id: i32) -> Message { + Message::Request(Request { + id: RequestId::from(id), + method: Shutdown::METHOD.to_string(), + params: serde_json::Value::Null, + }) +} + +fn server_shutdown(id: i32) -> Message { + Message::Response(Response::new_ok(RequestId::from(id), ())) +} + +fn exit() -> Message { + Message::Notification(Notification { + method: Exit::METHOD.to_string(), + params: serde_json::Value::Null, + }) +} + +#[test] +fn test_lsp_initialize() { + let input = join_messages(vec![initialize(1), initialized(), shutdown(2), exit()]); + let expected_output = join_messages(vec![server_initialized(1), server_shutdown(2)]); + + let mut cmd = create_stylua(); + let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(expected_output).unwrap() + ) +} + +#[test] +fn test_lsp_document_formatting() { + let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); + let contents = "local x = 1"; + + let input = join_messages(vec![ + initialize(1), + initialized(), + Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "lua".to_string(), + version: 0, + text: contents.to_string(), + }, + }) + .unwrap(), + }), + Message::Request(Request { + id: RequestId::from(2), + method: Formatting::METHOD.to_string(), + params: to_value(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri }, + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + }), + shutdown(3), + exit(), + ]); + + let expected_output = join_messages(vec![ + server_initialized(1), + Message::Response(Response::new_ok( + RequestId::from(2), + vec![TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 14), + }, + new_text: "local x = 1\n".to_string(), + }], + )), + server_shutdown(3), + ]); + + let mut cmd = create_stylua(); + let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(expected_output).unwrap() + ) +} + +#[test] +fn test_lsp_range_formatting() { + let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); + let contents = "local x = 1\nlocal y = 2"; + + let input = join_messages(vec![ + initialize(1), + initialized(), + Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "lua".to_string(), + version: 0, + text: contents.to_string(), + }, + }) + .unwrap(), + }), + Message::Request(Request { + id: RequestId::from(2), + method: RangeFormatting::METHOD.to_string(), + params: to_value(DocumentRangeFormattingParams { + text_document: TextDocumentIdentifier { uri }, + range: Range::new(Position::new(1, 0), Position::new(1, 18)), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + }), + shutdown(3), + exit(), + ]); + + let expected_output = join_messages(vec![ + server_initialized(1), + Message::Response(Response::new_ok( + RequestId::from(2), + vec![TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(1, 18), + }, + new_text: "local x = 1\nlocal y = 2\n".to_string(), + }], + )), + server_shutdown(3), + ]); + + let mut cmd = create_stylua(); + let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(expected_output).unwrap() + ) +} + +#[test] +fn test_lsp_ignore_formatting_for_non_lua_files() { + let uri = Uri::from_str("file:///home/documents/file.txt").unwrap(); + let contents = "local x"; + + let input = join_messages(vec![ + initialize(1), + initialized(), + Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "txt".to_string(), + version: 0, + text: contents.to_string(), + }, + }) + .unwrap(), + }), + Message::Request(Request { + id: RequestId::from(2), + method: Formatting::METHOD.to_string(), + params: to_value(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri }, + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + }), + shutdown(3), + exit(), + ]); + + let expected_output = join_messages(vec![ + server_initialized(1), + Message::Response(Response::new_ok( + RequestId::from(2), + serde_json::Value::Null, + )), + server_shutdown(3), + ]); + + let mut cmd = create_stylua(); + let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(expected_output).unwrap() + ) +} + +#[test] +fn test_lsp_fails_for_unknown_files() { + let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); + + let input = join_messages(vec![ + initialize(1), + initialized(), + Message::Request(Request { + id: RequestId::from(2), + method: Formatting::METHOD.to_string(), + params: to_value(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + }), + shutdown(3), + exit(), + ]); + + let expected_output = join_messages(vec![ + server_initialized(1), + Message::Response(Response::new_err( + RequestId::from(2), + ErrorCode::RequestFailed as i32, + format!("no document found for '{}'", uri.as_str()), + )), + server_shutdown(3), + ]); + + let mut cmd = create_stylua(); + let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(expected_output).unwrap() + ) +} From 48d07b4e0f1be80d9e816bbee9d040523d5d6b31 Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 17:04:17 +0200 Subject: [PATCH 4/8] Update help text --- src/cli/opt.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/opt.rs b/src/cli/opt.rs index a23a19a5..6e1999e3 100644 --- a/src/cli/opt.rs +++ b/src/cli/opt.rs @@ -109,7 +109,7 @@ pub struct Opt { #[structopt(long)] pub respect_ignores: bool, - /// Run Stylua LSP server + /// Run Stylua as a language server (following LSP protocol) #[structopt(long)] pub lsp: bool, } From e72d6757473c74a00e9e1f00b063c545e3479677 Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 17:04:22 +0200 Subject: [PATCH 5/8] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb840f5..956c0ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added option `block_newline_gaps` to determine whether newline gaps at the start / end of blocks should be preserved. Defaults to `Never`, which is the original behaviour. ([#857](https://github.com/JohnnyMorganz/StyLua/pull/857)) +- StyLua can now run in a language server mode. Start StyLua with `stylua --lsp` and connect with a language client. ([#936](https://github.com/JohnnyMorganz/StyLua/issues/936)) ### Changed From 9658825b9b9d0c1410cb463a8e716513cc8b6abc Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 17:11:09 +0200 Subject: [PATCH 6/8] Gate LSP behind feature (enabled by default) --- Cargo.toml | 10 +++++----- src/cli/main.rs | 12 ++++++++++-- tests/test_lsp_integration.rs | 1 + 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ceb1ae29..98df1d93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ path = "src/cli/main.rs" bench = false [features] -default = ["editorconfig", "wasm-bindgen"] +default = ["editorconfig", "wasm-bindgen", "lsp"] serialize = [] fromstr = ["strum"] luau = ["full_moon/roblox"] @@ -33,6 +33,7 @@ lua54 = ["lua53", "full_moon/lua54"] luajit = ["full_moon/luajit"] cfxlua = ["lua54", "full_moon/cfxlua"] editorconfig = ["ec4rs"] +lsp = ["lsp-server", "lsp-types", "lsp-textdocument"] [dependencies] anyhow = "1.0.75" @@ -56,10 +57,9 @@ strum = { version = "0.25.0", features = ["derive"], optional = true } thiserror = "1.0.49" threadpool = "1.8.1" toml = "0.8.1" - -lsp-server = "0.7" -lsp-types = "0.97" -lsp-textdocument = "0.4.2" +lsp-server = { version = "0.7", optional = true } +lsp-types = { version = "0.97", optional = true } +lsp-textdocument = { version = "0.4.2", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { version = "0.2.81", optional = true } diff --git a/src/cli/main.rs b/src/cli/main.rs index 857ad985..8c2d01e4 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -19,6 +19,7 @@ use stylua_lib::{format_code, Config, OutputVerification, Range}; use crate::config::find_ignore_file_path; mod config; +#[cfg(feature = "lsp")] mod lsp; mod opt; mod output_diff; @@ -267,8 +268,15 @@ fn format(opt: opt::Opt) -> Result { debug!("resolved options: {:#?}", opt); if opt.lsp { - lsp::run(opt)?; - return Ok(0); + #[cfg(feature = "lsp")] + { + lsp::run(opt)?; + return Ok(0); + } + #[cfg(not(feature = "lsp"))] + { + bail!("attempted to run stylua in LSP mode, but this binary was not built with 'lsp' feature enabled") + } } if opt.files.is_empty() { diff --git a/tests/test_lsp_integration.rs b/tests/test_lsp_integration.rs index 138aaad8..c7bde15d 100644 --- a/tests/test_lsp_integration.rs +++ b/tests/test_lsp_integration.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "lsp")] use std::str::FromStr; use assert_cmd::Command; From cd29873ed965bb5a194e6c8b444feccdc34f53b9 Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 17:14:25 +0200 Subject: [PATCH 7/8] Update readme --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e9f5a47..be3c3f8f 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,20 @@ Requires sorting is off by default. To enable it, add the following to your `sty enabled = true ``` +### Language Server Mode + +StyLua can run as a language server, connecting with language clients that follow the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/). +It will then respond to `textDocument/formatting` and `textDocument/rangeFormatting` requests. +Formatting is only performed on files with a `lua` or `luau` language ID. + +You can start the language server by running: + +```sh +stylua --lsp +``` + +StyLua will listen to LSP messages on stdin and respond on stdout. + ## Configuration StyLua has opinionated defaults, but also provides a few options that can be set per project. @@ -293,7 +307,7 @@ StyLua only offers the following options: | `quote_style` | `AutoPreferDouble` | Quote style for string literals. Possible options: `AutoPreferDouble`, `AutoPreferSingle`, `ForceDouble`, `ForceSingle`. `AutoPrefer` styles will prefer the specified quote style, but fall back to the alternative if it has fewer string escapes. `Force` styles always use the specified style regardless of escapes. | | `call_parentheses` | `Always` | Whether parentheses should be applied on function calls with a single string/table argument. Possible options: `Always`, `NoSingleString`, `NoSingleTable`, `None`, `Input`. `Always` applies parentheses in all cases. `NoSingleString` omits parentheses on calls with a single string argument. Similarly, `NoSingleTable` omits parentheses on calls with a single table argument. `None` omits parentheses in both cases. Note: parentheses are still kept in situations where removal can lead to obscurity (e.g. `foo "bar".setup -> foo("bar").setup`, since the index is on the call result, not the string). `Input` removes all automation and preserves parentheses only if they were present in input code: consistency is not enforced. | | `space_after_function_names` | `Never` | Specify whether to add a space between the function name and parentheses. Possible options: `Never`, `Definitions`, `Calls`, or `Always` | -| `block_newline_gaps` | `Never` | Specify whether to preserve leading and trailing newline gaps for blocks. Possible options: `Never`, `Preserve` | +| `block_newline_gaps` | `Never` | Specify whether to preserve leading and trailing newline gaps for blocks. Possible options: `Never`, `Preserve` | | `collapse_simple_statement` | `Never` | Specify whether to collapse simple statements. Possible options: `Never`, `FunctionOnly`, `ConditionalOnly`, or `Always` | Default `stylua.toml`, note you do not need to explicitly specify each option if you want to use the defaults: From d24b68db714588fa42d9f4fbe5119d917401c31a Mon Sep 17 00:00:00 2001 From: JohnnyMorganz Date: Sun, 14 Sep 2025 18:03:17 +0200 Subject: [PATCH 8/8] Fix flakiness of integration tests --- src/cli/lsp.rs | 371 ++++++++++++++++++++++++++++++++-- tests/test_lsp_integration.rs | 298 --------------------------- 2 files changed, 359 insertions(+), 310 deletions(-) delete mode 100644 tests/test_lsp_integration.rs diff --git a/src/cli/lsp.rs b/src/cli/lsp.rs index 374af648..4014d7fb 100644 --- a/src/cli/lsp.rs +++ b/src/cli/lsp.rs @@ -124,13 +124,7 @@ fn handle_request( } } -pub fn run(opt: opt::Opt) -> anyhow::Result<()> { - // Load the configuration - let opt_for_config_resolver = opt.clone(); - let mut config_resolver = ConfigResolver::new(&opt_for_config_resolver)?; - - let (connection, _io_threads) = Connection::stdio(); - +fn main_loop(connection: Connection, config_resolver: &mut ConfigResolver) -> anyhow::Result<()> { let capabilities = ServerCapabilities { document_formatting_provider: Some(OneOf::Left(true)), text_document_sync: Some(TextDocumentSyncCapability::Kind( @@ -138,11 +132,9 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { )), ..Default::default() }; - connection.initialize(serde_json::to_value(capabilities)?)?; let mut documents = TextDocuments::new(); - for msg in &connection.receiver { match msg { Message::Request(req) => { @@ -150,7 +142,7 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { break; } - let response = handle_request(req, &documents, &mut config_resolver); + let response = handle_request(req, &documents, config_resolver); connection.sender.send(Message::Response(response))? } Message::Response(_) => {} @@ -159,9 +151,364 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { } } } + Ok(()) +} - // HACK: to get our integration tests working and not hanging. We should probably join... - // io_threads.join()?; +pub fn run(opt: opt::Opt) -> anyhow::Result<()> { + // Load the configuration + let opt_for_config_resolver = opt.clone(); + let mut config_resolver = ConfigResolver::new(&opt_for_config_resolver)?; + + let (connection, io_threads) = Connection::stdio(); + + main_loop(connection, &mut config_resolver)?; + + io_threads.join()?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use clap::Parser; + use crossbeam_channel::Receiver; + use lsp_server::Connection; + + use lsp_server::{ErrorCode, Message, Notification, Request, RequestId, Response}; + use lsp_types::{ + notification::{DidOpenTextDocument, Exit, Initialized, Notification as NotificationType}, + request::{Formatting, Initialize, RangeFormatting, Request as RequestType, Shutdown}, + DidOpenTextDocumentParams, DocumentFormattingParams, DocumentRangeFormattingParams, + FormattingOptions, InitializeParams, Position, Range, TextDocumentIdentifier, + TextDocumentItem, TextEdit, Uri, WorkDoneProgressParams, + }; + use lsp_types::{OneOf, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind}; + use serde::de::DeserializeOwned; + use serde_json::to_value; + + use crate::{config::ConfigResolver, lsp::main_loop, opt::Opt}; + + fn initialize(id: i32) -> Message { + Message::Request(Request { + id: RequestId::from(id), + method: ::METHOD.to_string(), + params: to_value(InitializeParams::default()).unwrap(), + }) + } + + fn initialized() -> Message { + Message::Notification(Notification { + method: Initialized::METHOD.to_string(), + params: serde_json::Value::Null, + }) + } + + fn shutdown(id: i32) -> Message { + Message::Request(Request { + id: RequestId::from(id), + method: Shutdown::METHOD.to_string(), + params: serde_json::Value::Null, + }) + } + + fn exit() -> Message { + Message::Notification(Notification { + method: Exit::METHOD.to_string(), + params: serde_json::Value::Null, + }) + } + + fn expect_server_initialized(receiver: &Receiver, response_id: i32) { + match receiver.recv().unwrap() { + Message::Response(Response { + id, + result: Some(result), + error: None, + }) if id == RequestId::from(response_id) + && result + == serde_json::json!({ + "capabilities": ServerCapabilities { + document_formatting_provider: Some(OneOf::Left(true)), + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::INCREMENTAL, + )), + ..Default::default() + }}) => {} + _ => panic!("assertion failed"), + } + } + + fn expect_response(receiver: &Receiver, response_id: i32) -> T { + match receiver.recv().unwrap() { + Message::Response(Response { + id, + result: Some(result), + error: None, + }) if id == RequestId::from(response_id) => { + serde_json::from_value::(result).unwrap() + } + _ => panic!("assertion failed"), + } + } + + fn expect_server_shutdown(receiver: &Receiver, response_id: i32) { + match receiver.recv().unwrap() { + Message::Response(Response { + id, + result: Some(result), + error: None, + }) if id == RequestId::from(response_id) && result == serde_json::Value::Null => {} + _ => panic!("assertion failed"), + } + } + + #[test] + fn test_lsp_initialize() { + let opt = Opt::parse_from(vec!["BINARY_NAME"]); + let mut config_resolver = ConfigResolver::new(&opt).unwrap(); + + let (server, client) = Connection::memory(); + client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialized()).unwrap(); + client.sender.send(shutdown(2)).unwrap(); + client.sender.send(exit()).unwrap(); + + main_loop(server, &mut config_resolver).unwrap(); + + expect_server_initialized(&client.receiver, 1); + expect_server_shutdown(&client.receiver, 2); + assert!(client.receiver.is_empty()); + } + + #[test] + fn test_lsp_document_formatting() { + let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); + let contents = "local x = 1"; + + let opt = Opt::parse_from(vec!["BINARY_NAME"]); + let mut config_resolver = ConfigResolver::new(&opt).unwrap(); + + let (server, client) = Connection::memory(); + client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialized()).unwrap(); + client + .sender + .send(Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "lua".to_string(), + version: 0, + text: contents.to_string(), + }, + }) + .unwrap(), + })) + .unwrap(); + client + .sender + .send(Message::Request(Request { + id: RequestId::from(2), + method: Formatting::METHOD.to_string(), + params: to_value(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri }, + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + })) + .unwrap(); + client.sender.send(shutdown(3)).unwrap(); + client.sender.send(exit()).unwrap(); + + main_loop(server, &mut config_resolver).unwrap(); + + expect_server_initialized(&client.receiver, 1); + + let edits: Vec = expect_response(&client.receiver, 2); + assert_eq!(edits.len(), 1); + assert_eq!( + edits[0], + TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(0, 14), + }, + new_text: "local x = 1\n".to_string(), + } + ); + + expect_server_shutdown(&client.receiver, 3); + assert!(client.receiver.is_empty()); + } + + #[test] + fn test_lsp_range_formatting() { + let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); + let contents = "local x = 1\nlocal y = 2"; + + let opt = Opt::parse_from(vec!["BINARY_NAME"]); + let mut config_resolver = ConfigResolver::new(&opt).unwrap(); + + let (server, client) = Connection::memory(); + client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialized()).unwrap(); + client + .sender + .send(Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "lua".to_string(), + version: 0, + text: contents.to_string(), + }, + }) + .unwrap(), + })) + .unwrap(); + client + .sender + .send(Message::Request(Request { + id: RequestId::from(2), + method: RangeFormatting::METHOD.to_string(), + params: to_value(DocumentRangeFormattingParams { + text_document: TextDocumentIdentifier { uri }, + range: Range::new(Position::new(1, 0), Position::new(1, 18)), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + })) + .unwrap(); + client.sender.send(shutdown(3)).unwrap(); + client.sender.send(exit()).unwrap(); + + main_loop(server, &mut config_resolver).unwrap(); + + expect_server_initialized(&client.receiver, 1); + + let edits: Vec = expect_response(&client.receiver, 2); + assert_eq!(edits.len(), 1); + assert_eq!( + edits[0], + TextEdit { + range: Range { + start: Position::new(0, 0), + end: Position::new(1, 18), + }, + new_text: "local x = 1\nlocal y = 2\n".to_string(), + } + ); + + expect_server_shutdown(&client.receiver, 3); + assert!(client.receiver.is_empty()); + } + + #[test] + fn test_lsp_ignore_formatting_for_non_lua_files() { + let uri = Uri::from_str("file:///home/documents/file.txt").unwrap(); + let contents = "local x"; + + let opt = Opt::parse_from(vec!["BINARY_NAME"]); + let mut config_resolver = ConfigResolver::new(&opt).unwrap(); + + let (server, client) = Connection::memory(); + client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialized()).unwrap(); + client + .sender + .send(Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "txt".to_string(), + version: 0, + text: contents.to_string(), + }, + }) + .unwrap(), + })) + .unwrap(); + client + .sender + .send(Message::Request(Request { + id: RequestId::from(2), + method: RangeFormatting::METHOD.to_string(), + params: to_value(DocumentRangeFormattingParams { + text_document: TextDocumentIdentifier { uri }, + range: Range::new(Position::new(1, 0), Position::new(1, 18)), + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + })) + .unwrap(); + client.sender.send(shutdown(3)).unwrap(); + client.sender.send(exit()).unwrap(); + + main_loop(server, &mut config_resolver).unwrap(); + + expect_server_initialized(&client.receiver, 1); + + let edits: serde_json::Value = expect_response(&client.receiver, 2); + assert_eq!(edits, serde_json::Value::Null); + + expect_server_shutdown(&client.receiver, 3); + assert!(client.receiver.is_empty()); + } + + #[test] + fn test_lsp_fails_for_unknown_files() { + let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); + + let opt = Opt::parse_from(vec!["BINARY_NAME"]); + let mut config_resolver = ConfigResolver::new(&opt).unwrap(); + + let (server, client) = Connection::memory(); + client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialized()).unwrap(); + client + .sender + .send(Message::Request(Request { + id: RequestId::from(2), + method: Formatting::METHOD.to_string(), + params: to_value(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + options: FormattingOptions::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + })) + .unwrap(); + client.sender.send(shutdown(3)).unwrap(); + client.sender.send(exit()).unwrap(); + + main_loop(server, &mut config_resolver).unwrap(); + + expect_server_initialized(&client.receiver, 1); + + let Message::Response(Response { + id, + result: None, + error: Some(error), + }) = client.receiver.recv().unwrap() + else { + unreachable!() + }; + assert!(id == RequestId::from(2)); + assert_eq!(error.code, ErrorCode::RequestFailed as i32); + assert_eq!( + error.message, + format!("no document found for '{}'", uri.as_str()) + ); + + expect_server_shutdown(&client.receiver, 3); + assert!(client.receiver.is_empty()); + } +} diff --git a/tests/test_lsp_integration.rs b/tests/test_lsp_integration.rs deleted file mode 100644 index c7bde15d..00000000 --- a/tests/test_lsp_integration.rs +++ /dev/null @@ -1,298 +0,0 @@ -#![cfg(feature = "lsp")] -use std::str::FromStr; - -use assert_cmd::Command; - -use lsp_server::{ErrorCode, Message, Notification, Request, RequestId, Response}; -use lsp_types::{ - notification::{DidOpenTextDocument, Exit, Initialized, Notification as NotificationType}, - request::{Formatting, Initialize, RangeFormatting, Request as RequestType, Shutdown}, - DidOpenTextDocumentParams, DocumentFormattingParams, DocumentRangeFormattingParams, - FormattingOptions, InitializeParams, OneOf, Position, Range, ServerCapabilities, - TextDocumentIdentifier, TextDocumentItem, TextDocumentSyncCapability, TextDocumentSyncKind, - TextEdit, Uri, WorkDoneProgressParams, -}; -use serde_json::to_value; - -fn create_stylua() -> Command { - Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() -} - -fn join_messages(input: Vec) -> Vec { - let mut buffer = Vec::new(); - input - .iter() - .for_each(|message| message.clone().write(&mut buffer).unwrap()); - buffer -} - -fn initialize(id: i32) -> Message { - Message::Request(Request { - id: RequestId::from(id), - method: ::METHOD.to_string(), - params: to_value(InitializeParams::default()).unwrap(), - }) -} - -fn server_initialized(id: i32) -> Message { - Message::Response(Response::new_ok( - RequestId::from(id), - serde_json::json!({ - "capabilities": ServerCapabilities { - document_formatting_provider: Some(OneOf::Left(true)), - text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::INCREMENTAL, - )), - ..Default::default() - }}), - )) -} - -fn initialized() -> Message { - Message::Notification(Notification { - method: Initialized::METHOD.to_string(), - params: serde_json::Value::Null, - }) -} - -fn shutdown(id: i32) -> Message { - Message::Request(Request { - id: RequestId::from(id), - method: Shutdown::METHOD.to_string(), - params: serde_json::Value::Null, - }) -} - -fn server_shutdown(id: i32) -> Message { - Message::Response(Response::new_ok(RequestId::from(id), ())) -} - -fn exit() -> Message { - Message::Notification(Notification { - method: Exit::METHOD.to_string(), - params: serde_json::Value::Null, - }) -} - -#[test] -fn test_lsp_initialize() { - let input = join_messages(vec![initialize(1), initialized(), shutdown(2), exit()]); - let expected_output = join_messages(vec![server_initialized(1), server_shutdown(2)]); - - let mut cmd = create_stylua(); - let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(expected_output).unwrap() - ) -} - -#[test] -fn test_lsp_document_formatting() { - let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); - let contents = "local x = 1"; - - let input = join_messages(vec![ - initialize(1), - initialized(), - Message::Notification(Notification { - method: DidOpenTextDocument::METHOD.to_string(), - params: to_value(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lua".to_string(), - version: 0, - text: contents.to_string(), - }, - }) - .unwrap(), - }), - Message::Request(Request { - id: RequestId::from(2), - method: Formatting::METHOD.to_string(), - params: to_value(DocumentFormattingParams { - text_document: TextDocumentIdentifier { uri }, - options: FormattingOptions::default(), - work_done_progress_params: WorkDoneProgressParams::default(), - }) - .unwrap(), - }), - shutdown(3), - exit(), - ]); - - let expected_output = join_messages(vec![ - server_initialized(1), - Message::Response(Response::new_ok( - RequestId::from(2), - vec![TextEdit { - range: Range { - start: Position::new(0, 0), - end: Position::new(0, 14), - }, - new_text: "local x = 1\n".to_string(), - }], - )), - server_shutdown(3), - ]); - - let mut cmd = create_stylua(); - let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(expected_output).unwrap() - ) -} - -#[test] -fn test_lsp_range_formatting() { - let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); - let contents = "local x = 1\nlocal y = 2"; - - let input = join_messages(vec![ - initialize(1), - initialized(), - Message::Notification(Notification { - method: DidOpenTextDocument::METHOD.to_string(), - params: to_value(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "lua".to_string(), - version: 0, - text: contents.to_string(), - }, - }) - .unwrap(), - }), - Message::Request(Request { - id: RequestId::from(2), - method: RangeFormatting::METHOD.to_string(), - params: to_value(DocumentRangeFormattingParams { - text_document: TextDocumentIdentifier { uri }, - range: Range::new(Position::new(1, 0), Position::new(1, 18)), - options: FormattingOptions::default(), - work_done_progress_params: WorkDoneProgressParams::default(), - }) - .unwrap(), - }), - shutdown(3), - exit(), - ]); - - let expected_output = join_messages(vec![ - server_initialized(1), - Message::Response(Response::new_ok( - RequestId::from(2), - vec![TextEdit { - range: Range { - start: Position::new(0, 0), - end: Position::new(1, 18), - }, - new_text: "local x = 1\nlocal y = 2\n".to_string(), - }], - )), - server_shutdown(3), - ]); - - let mut cmd = create_stylua(); - let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(expected_output).unwrap() - ) -} - -#[test] -fn test_lsp_ignore_formatting_for_non_lua_files() { - let uri = Uri::from_str("file:///home/documents/file.txt").unwrap(); - let contents = "local x"; - - let input = join_messages(vec![ - initialize(1), - initialized(), - Message::Notification(Notification { - method: DidOpenTextDocument::METHOD.to_string(), - params: to_value(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: uri.clone(), - language_id: "txt".to_string(), - version: 0, - text: contents.to_string(), - }, - }) - .unwrap(), - }), - Message::Request(Request { - id: RequestId::from(2), - method: Formatting::METHOD.to_string(), - params: to_value(DocumentFormattingParams { - text_document: TextDocumentIdentifier { uri }, - options: FormattingOptions::default(), - work_done_progress_params: WorkDoneProgressParams::default(), - }) - .unwrap(), - }), - shutdown(3), - exit(), - ]); - - let expected_output = join_messages(vec![ - server_initialized(1), - Message::Response(Response::new_ok( - RequestId::from(2), - serde_json::Value::Null, - )), - server_shutdown(3), - ]); - - let mut cmd = create_stylua(); - let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(expected_output).unwrap() - ) -} - -#[test] -fn test_lsp_fails_for_unknown_files() { - let uri = Uri::from_str("file:///home/documents/file.luau").unwrap(); - - let input = join_messages(vec![ - initialize(1), - initialized(), - Message::Request(Request { - id: RequestId::from(2), - method: Formatting::METHOD.to_string(), - params: to_value(DocumentFormattingParams { - text_document: TextDocumentIdentifier { uri: uri.clone() }, - options: FormattingOptions::default(), - work_done_progress_params: WorkDoneProgressParams::default(), - }) - .unwrap(), - }), - shutdown(3), - exit(), - ]); - - let expected_output = join_messages(vec![ - server_initialized(1), - Message::Response(Response::new_err( - RequestId::from(2), - ErrorCode::RequestFailed as i32, - format!("no document found for '{}'", uri.as_str()), - )), - server_shutdown(3), - ]); - - let mut cmd = create_stylua(); - let output = cmd.arg("--lsp").write_stdin(input).output().unwrap(); - assert!(output.status.success()); - assert_eq!( - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(expected_output).unwrap() - ) -}