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 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..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,6 +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 = { 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/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: diff --git a/src/cli/lsp.rs b/src/cli/lsp.rs new file mode 100644 index 00000000..4014d7fb --- /dev/null +++ b/src/cli/lsp.rs @@ -0,0 +1,514 @@ +use std::convert::TryInto; + +use lsp_server::{Connection, ErrorCode, Message, Response}; +use lsp_textdocument::{FullTextDocument, TextDocuments}; +use lsp_types::{ + request::{Formatting, RangeFormatting, Request}, + 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), + ), + } +} + +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( + 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; + } + + let response = handle_request(req, &documents, config_resolver); + connection.sender.send(Message::Response(response))? + } + Message::Response(_) => {} + Message::Notification(notification) => { + documents.listen(notification.method.as_str(), ¬ification.params); + } + } + } + Ok(()) +} + +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/src/cli/main.rs b/src/cli/main.rs index 16eead5c..8c2d01e4 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -19,6 +19,8 @@ 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; @@ -265,6 +267,18 @@ 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 { + #[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() { bail!("no files provided"); } diff --git a/src/cli/opt.rs b/src/cli/opt.rs index e38ef3a3..6e1999e3 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 as a language server (following LSP protocol) + #[structopt(long)] + pub lsp: bool, } #[derive(ArgEnum, Clone, Copy, Debug, PartialEq, Eq)]