diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e891343..0d9eb45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed comments lost from expression after parentheses are removed when we are attempting to "hang" the expression. ([#1033](https://github.com/JohnnyMorganz/StyLua/issues/1033)) - Fixed `document_range_formatting_provider` capability missing from `ServerCapabilities` in language server mode +- Fixed current working directory incorrectly used as config search root in language server mode -- now, the root of the opened workspace is used instead ([#1032](https://github.com/JohnnyMorganz/StyLua/issues/1032)) ## [2.2.0] - 2025-09-14 diff --git a/src/cli/config.rs b/src/cli/config.rs index 33ea7c92..ca316a18 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -58,19 +58,28 @@ impl ConfigResolver<'_> { /// Returns the root used when searching for configuration /// If `--search-parent-directories`, then there is no root, and we keep searching /// Else, the root is the current working directory, and we do not search higher than the cwd - fn get_configuration_search_root(&self) -> Option { + fn get_configuration_search_root( + &self, + search_root_override: Option, + ) -> Option { match self.opt.search_parent_directories { true => None, - false => Some(self.current_directory.to_path_buf()), + false => { + Some(search_root_override.unwrap_or_else(|| self.current_directory.to_path_buf())) + } } } - pub fn load_configuration(&mut self, path: &Path) -> Result { + pub(crate) fn load_configuration_with_search_root( + &mut self, + path: &Path, + search_root_override: Option, + ) -> Result { if let Some(configuration) = self.forced_configuration { return Ok(configuration); } - let root = self.get_configuration_search_root(); + let root = self.get_configuration_search_root(search_root_override); let absolute_path = self.current_directory.join(path); let parent_path = &absolute_path @@ -93,12 +102,16 @@ impl ConfigResolver<'_> { } } + pub fn load_configuration(&mut self, path: &Path) -> Result { + self.load_configuration_with_search_root(path, None) + } + pub fn load_configuration_for_stdin(&mut self) -> Result { if let Some(configuration) = self.forced_configuration { return Ok(configuration); } - let root = self.get_configuration_search_root(); + let root = self.get_configuration_search_root(None); let my_current_directory = self.current_directory.to_owned(); match &self.opt.stdin_filepath { diff --git a/src/cli/lsp.rs b/src/cli/lsp.rs index 0fd1abf5..be85dc05 100644 --- a/src/cli/lsp.rs +++ b/src/cli/lsp.rs @@ -1,4 +1,4 @@ -use std::convert::TryInto; +use std::{convert::TryInto, path::PathBuf}; use lsp_server::{Connection, ErrorCode, Message, Response}; use lsp_textdocument::{FullTextDocument, TextDocuments}; @@ -6,7 +6,7 @@ use lsp_types::{ request::{Formatting, RangeFormatting, Request}, DocumentFormattingParams, DocumentRangeFormattingParams, FormattingOptions, InitializeParams, InitializeResult, OneOf, Range, ServerCapabilities, ServerInfo, TextDocumentSyncCapability, - TextDocumentSyncKind, TextEdit, Uri, + TextDocumentSyncKind, TextEdit, Uri, WorkspaceFolder, }; use serde::Deserialize; use similar::{DiffOp, TextDiff}; @@ -60,131 +60,201 @@ fn diffop_to_textedit( } } -fn handle_formatting( - uri: &Uri, - document: &FullTextDocument, - range: Option, - config_resolver: &mut ConfigResolver, - formatting_options: Option<&FormattingOptions>, -) -> Option> { - if document.language_id() != "lua" && document.language_id() != "luau" { - return None; +struct LanguageServer<'a> { + documents: TextDocuments, + workspace_folders: Vec, + root_uri: Option, + respect_editor_formatting_options: bool, + config_resolver: &'a mut ConfigResolver<'a>, +} + +enum FormattingError { + StyLuaError, + NotLuaDocument, + DocumentNotFound, +} + +impl LanguageServer<'_> { + fn new<'a>( + workspace_folders: Vec, + root_uri: Option, + respect_editor_formatting_options: bool, + config_resolver: &'a mut ConfigResolver<'a>, + ) -> LanguageServer<'a> { + LanguageServer { + documents: TextDocuments::new(), + workspace_folders, + root_uri, + respect_editor_formatting_options, + config_resolver, + } } - let contents = document.get_content(None); + fn find_config_root(&self, uri: &Uri) -> PathBuf { + let mut best_workspace = None; + let mut best_len = 0; + let check_str = uri.as_str(); + for workspace in &self.workspace_folders { + if *uri == workspace.uri { + return workspace.uri.path().as_str().into(); + } - let mut config = config_resolver - .load_configuration(uri.path().as_str().as_ref()) - .unwrap_or_default(); + let prefix_str = workspace.uri.as_str(); + if prefix_str.len() < best_len { + continue; + } - if let Some(formatting_options) = formatting_options { - config.indent_width = formatting_options - .tab_size - .try_into() - .expect("u32 fits into usize"); - config.indent_type = if formatting_options.insert_spaces { - IndentType::Spaces - } else { - IndentType::Tabs - }; + if check_str.starts_with(prefix_str) { + best_workspace = Some(&workspace.uri); + best_len = prefix_str.len() + } + } + + match best_workspace { + Some(workspace) => workspace.path().as_str().into(), + None => match &self.root_uri { + Some(root_uri) => root_uri.path().as_str().into(), + None => std::env::current_dir().expect("Could not find current directory"), + }, + } } - let formatted_contents = format_code(contents, config, range, OutputVerification::None).ok()?; + fn handle_formatting( + &mut self, + uri: &Uri, + range: Option, + formatting_options: Option<&FormattingOptions>, + ) -> Result, FormattingError> { + let Some(document) = self.documents.get_document(uri) else { + return Err(FormattingError::DocumentNotFound); + }; - let operations = - TextDiff::from_chars(contents.as_bytes(), formatted_contents.as_bytes()).grouped_ops(0); - let edits = operations - .into_iter() - .flat_map(|operations| { - operations - .into_iter() - .filter_map(|op| diffop_to_textedit(op, document, &formatted_contents)) - }) - .collect(); - Some(edits) -} + let range = range.map(|lsp_range| { + stylua_lib::Range::from_values( + Some(document.offset_at(lsp_range.start).try_into().unwrap()), + Some(document.offset_at(lsp_range.end).try_into().unwrap()), + ) + }); -fn handle_request( - request: lsp_server::Request, - documents: &TextDocuments, - config_resolver: &mut ConfigResolver, - respect_editor_formatting_options: bool, -) -> 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() + if document.language_id() != "lua" && document.language_id() != "luau" { + return Err(FormattingError::NotLuaDocument); + } + + let contents = document.get_content(None); + + let mut config = self + .config_resolver + .load_configuration_with_search_root( + uri.path().as_str().as_ref(), + Some(self.find_config_root(uri)), + ) + .unwrap_or_default(); + + if let Some(formatting_options) = formatting_options { + config.indent_width = formatting_options + .tab_size + .try_into() + .expect("u32 fits into usize"); + config.indent_type = if formatting_options.insert_spaces { + IndentType::Spaces + } else { + IndentType::Tabs + }; + } + + let Ok(formatted_contents) = format_code(contents, config, range, OutputVerification::None) + else { + return Err(FormattingError::StyLuaError); + }; + + let operations = + TextDiff::from_chars(contents.as_bytes(), formatted_contents.as_bytes()).grouped_ops(0); + let edits = operations + .into_iter() + .flat_map(|operations| { + operations + .into_iter() + .filter_map(|op| diffop_to_textedit(op, document, &formatted_contents)) + }) + .collect(); + Ok(edits) + } + + fn handle_request(&mut self, request: lsp_server::Request) -> Response { + match request.method.as_str() { + Formatting::METHOD => { + match serde_json::from_value::(request.params) { + Ok(params) => { + match self.handle_formatting( + ¶ms.text_document.uri, + None, + self.respect_editor_formatting_options + .then_some(¶ms.options), + ) { + Ok(edits) => Response::new_ok(request.id, edits), + Err(FormattingError::StyLuaError) + | Err(FormattingError::NotLuaDocument) => { + Response::new_ok(request.id, serde_json::Value::Null) + } + Err(FormattingError::DocumentNotFound) => 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, - respect_editor_formatting_options.then_some(¶ms.options), - ) { - 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(), + ), } - 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() + RangeFormatting::METHOD => { + match serde_json::from_value::(request.params) { + Ok(params) => { + match self.handle_formatting( + ¶ms.text_document.uri, + Some(params.range), + self.respect_editor_formatting_options + .then_some(¶ms.options), + ) { + Ok(edits) => Response::new_ok(request.id, edits), + Err(FormattingError::StyLuaError) + | Err(FormattingError::NotLuaDocument) => { + Response::new_ok(request.id, serde_json::Value::Null) + } + Err(FormattingError::DocumentNotFound) => Response::new_err( + request.id, + ErrorCode::RequestFailed as i32, + 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, - respect_editor_formatting_options.then_some(¶ms.options), - ) { - 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(), + ), } - 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), + ), } - _ => Response::new_err( - request.id, - lsp_server::ErrorCode::MethodNotFound as i32, - format!("server does not support method '{}'", request.method), - ), + } + + fn handle_notification(&mut self, notification: lsp_server::Notification) { + self.documents + .listen(notification.method.as_str(), ¬ification.params); } } @@ -194,7 +264,10 @@ struct InitializationOptions { respect_editor_formatting_options: Option, } -fn main_loop(connection: Connection, config_resolver: &mut ConfigResolver) -> anyhow::Result<()> { +fn main_loop<'a>( + connection: Connection, + config_resolver: &'a mut ConfigResolver<'a>, +) -> anyhow::Result<()> { let initialize_result = InitializeResult { capabilities: ServerCapabilities { document_range_formatting_provider: Some(OneOf::Left(true)), @@ -221,7 +294,14 @@ fn main_loop(connection: Connection, config_resolver: &mut ConfigResolver) -> an connection.initialize_finish(id, serde_json::to_value(initialize_result)?)?; - let mut documents = TextDocuments::new(); + let mut language_server = LanguageServer::new( + initialize_params.workspace_folders.unwrap_or_default(), + #[allow(deprecated)] + initialize_params.root_uri, + respect_editor_formatting_options, + config_resolver, + ); + for msg in &connection.receiver { match msg { Message::Request(req) => { @@ -229,17 +309,12 @@ fn main_loop(connection: Connection, config_resolver: &mut ConfigResolver) -> an break; } - let response = handle_request( - req, - &documents, - config_resolver, - respect_editor_formatting_options, - ); + let response = language_server.handle_request(req); connection.sender.send(Message::Response(response))? } Message::Response(_) => {} Message::Notification(notification) => { - documents.listen(notification.method.as_str(), ¬ification.params); + language_server.handle_notification(notification) } } } @@ -264,6 +339,7 @@ pub fn run(opt: opt::Opt) -> anyhow::Result<()> { mod tests { use std::cmp::Ordering; use std::convert::TryInto; + use std::path::Path; use std::str::FromStr; use clap::Parser; @@ -286,11 +362,57 @@ mod tests { use crate::{config::ConfigResolver, lsp::main_loop, opt::Opt}; - fn initialize(id: i32) -> Message { + use assert_fs::prelude::*; + + macro_rules! construct_tree { + ({ $($file_name:literal:$file_contents:literal,)* }) => {{ + let cwd = assert_fs::TempDir::new().unwrap(); + + $( + cwd.child($file_name).write_str($file_contents).unwrap(); + )* + + cwd + }}; + ({ $($file_name:literal:$file_contents:expr,)* }) => {{ + let cwd = assert_fs::TempDir::new().unwrap(); + + $( + cwd.child($file_name).write_str($file_contents).unwrap(); + )* + + cwd + }}; + } + + macro_rules! lsp_test { + ($cwd:expr, [$( $arguments:expr ),*], [$( $messages:expr ),*], [$( $tests:expr ),*]) => { + let opt = Opt::parse_from(vec!["BINARY_NAME", "--lsp", $($arguments)*]); + let mut config_resolver = ConfigResolver::new(&opt).unwrap(); + + let (server, client) = Connection::memory(); + $( + client.sender.send($messages).unwrap(); + )* + + main_loop(server, &mut config_resolver).unwrap(); + + $( + $tests(&client.receiver); + )* + }; + } + + fn initialize(id: i32, root_path: Option<&Path>) -> Message { Message::Request(Request { id: RequestId::from(id), method: ::METHOD.to_string(), - params: to_value(InitializeParams::default()).unwrap(), + params: to_value(InitializeParams { + #[allow(deprecated)] + root_uri: root_path.map(|path| Uri::from_str(path.to_str().unwrap()).unwrap()), + ..Default::default() + }) + .unwrap(), }) } @@ -301,6 +423,34 @@ mod tests { }) } + fn open_text_document(uri: Uri, text: String) -> Message { + Message::Notification(Notification { + method: DidOpenTextDocument::METHOD.to_string(), + params: to_value(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "lua".to_string(), + version: 0, + text, + }, + }) + .unwrap(), + }) + } + + fn format_document(id: i32, uri: Uri, options: FormattingOptions) -> Message { + Message::Request(Request { + id: RequestId::from(id), + method: Formatting::METHOD.to_string(), + params: to_value(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri }, + options, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .unwrap(), + }) + } + fn shutdown(id: i32) -> Message { Message::Request(Request { id: RequestId::from(id), @@ -372,7 +522,7 @@ mod tests { let mut config_resolver = ConfigResolver::new(&opt).unwrap(); let (server, client) = Connection::memory(); - client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialize(1, None)).unwrap(); client.sender.send(initialized()).unwrap(); client.sender.send(shutdown(2)).unwrap(); client.sender.send(exit()).unwrap(); @@ -422,35 +572,19 @@ mod tests { let mut config_resolver = ConfigResolver::new(&opt).unwrap(); let (server, client) = Connection::memory(); - client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialize(1, None)).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(), - })) + .send(open_text_document(uri.clone(), contents.to_string())) .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(), - })) + .send(format_document( + 2, + uri.clone(), + FormattingOptions::default(), + )) .unwrap(); client.sender.send(shutdown(3)).unwrap(); client.sender.send(exit()).unwrap(); @@ -497,22 +631,11 @@ mod tests { let mut config_resolver = ConfigResolver::new(&opt).unwrap(); let (server, client) = Connection::memory(); - client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialize(1, None)).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(), - })) + .send(open_text_document(uri.clone(), contents.to_string())) .unwrap(); client .sender @@ -581,7 +704,7 @@ mod tests { let mut config_resolver = ConfigResolver::new(&opt).unwrap(); let (server, client) = Connection::memory(); - client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialize(1, None)).unwrap(); client.sender.send(initialized()).unwrap(); client .sender @@ -634,20 +757,15 @@ mod tests { let mut config_resolver = ConfigResolver::new(&opt).unwrap(); let (server, client) = Connection::memory(); - client.sender.send(initialize(1)).unwrap(); + client.sender.send(initialize(1, None)).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(), - })) + .send(format_document( + 2, + uri.clone(), + FormattingOptions::default(), + )) .unwrap(); client.sender.send(shutdown(3)).unwrap(); client.sender.send(exit()).unwrap(); @@ -674,4 +792,171 @@ mod tests { expect_server_shutdown(&client.receiver, 3); assert!(client.receiver.is_empty()); } + + #[test] + fn test_lsp_respects_configuration_in_root_path() { + let contents = "local x = \"hello\""; + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "foo.lua": contents, + }); + + let uri = Uri::from_str(cwd.child("foo.lua").to_str().unwrap()).unwrap(); + + lsp_test!( + cwd, + [], + [ + initialize(1, Some(cwd.path())), + initialized(), + open_text_document(uri.clone(), contents.to_string()), + format_document(2, uri.clone(), FormattingOptions::default()), + shutdown(3), + exit() + ], + [ + |receiver| expect_server_initialized(receiver, 1), + |receiver| { + let edits: Vec = expect_response(receiver, 2); + let formatted = apply_text_edits_to(contents, edits); + assert_eq!(formatted, "local x = 'hello'\n"); + }, + |receiver| expect_server_shutdown(receiver, 3) + ] + ); + } + + #[test] + fn test_lsp_cwd_configuration_respected_for_nested_file() { + let contents = "local x = \"hello\""; + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": contents, + }); + + let uri = Uri::from_str(cwd.child("foo.lua").to_str().unwrap()).unwrap(); + + lsp_test!( + cwd, + [], + [ + initialize(1, Some(cwd.path())), + initialized(), + open_text_document(uri.clone(), contents.to_string()), + format_document(2, uri.clone(), FormattingOptions::default()), + shutdown(3), + exit() + ], + [ + |receiver| expect_server_initialized(receiver, 1), + |receiver| { + let edits: Vec = expect_response(receiver, 2); + let formatted = apply_text_edits_to(contents, edits); + assert_eq!(formatted, "local x = 'hello'\n"); + }, + |receiver| expect_server_shutdown(receiver, 3) + ] + ); + } + + #[test] + fn test_lsp_configuration_is_not_used_outside_of_cwd() { + let contents = "local x = \"hello\""; + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": contents, + }); + + let cwd = cwd.child("build"); + let uri = Uri::from_str(cwd.child("foo.lua").to_str().unwrap()).unwrap(); + + lsp_test!( + cwd, + [], + [ + initialize(1, Some(cwd.path())), + initialized(), + open_text_document(uri.clone(), contents.to_string()), + format_document(2, uri.clone(), FormattingOptions::default()), + shutdown(3), + exit() + ], + [ + |receiver| expect_server_initialized(receiver, 1), + |receiver| { + let edits: Vec = expect_response(receiver, 2); + let formatted = apply_text_edits_to(contents, edits); + assert_eq!(formatted, "local x = \"hello\"\n"); + }, + |receiver| expect_server_shutdown(receiver, 3) + ] + ); + } + + #[test] + fn test_lsp_configuration_used_outside_of_cwd_when_search_parent_directories_is_enabled() { + let contents = "local x = \"hello\""; + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": contents, + }); + + let cwd = cwd.child("build"); + let uri = Uri::from_str(cwd.child("foo.lua").to_str().unwrap()).unwrap(); + + lsp_test!( + cwd, + ["--search-parent-directories"], + [ + initialize(1, Some(cwd.path())), + initialized(), + open_text_document(uri.clone(), contents.to_string()), + format_document(2, uri.clone(), FormattingOptions::default()), + shutdown(3), + exit() + ], + [ + |receiver| expect_server_initialized(receiver, 1), + |receiver| { + let edits: Vec = expect_response(receiver, 2); + let formatted = apply_text_edits_to(contents, edits); + assert_eq!(formatted, "local x = 'hello'\n"); + }, + |receiver| expect_server_shutdown(receiver, 3) + ] + ); + } + + #[test] + fn test_lsp_configuration_is_searched_next_to_file() { + let contents = "local x = \"hello\""; + let cwd = construct_tree!({ + "build/stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": contents, + }); + + let uri = Uri::from_str(cwd.child("build/foo.lua").to_str().unwrap()).unwrap(); + + lsp_test!( + cwd, + [], + [ + initialize(1, Some(cwd.path())), + initialized(), + open_text_document(uri.clone(), contents.to_string()), + format_document(2, uri.clone(), FormattingOptions::default()), + shutdown(3), + exit() + ], + [ + |receiver| expect_server_initialized(receiver, 1), + |receiver| { + let edits: Vec = expect_response(receiver, 2); + let formatted = apply_text_edits_to(contents, edits); + assert_eq!(formatted, "local x = 'hello'\n"); + }, + |receiver| expect_server_shutdown(receiver, 3) + ] + ); + } }