diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e838a8..9a7de0a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update internal Lua parser version (full-moon) to v1.1.0. This includes parser performance improvements. ([#854](https://github.com/JohnnyMorganz/StyLua/issues/854)) - LuaJIT is now separated from Lua52, and is available in its own feature and syntax flag +- `.stylua.toml` config resolution now supports looking up config files next to files being formatted, recursively going + upwards until reaching the current working directory, then stopping (unless `--search-parent-directories` was specified). + For example, for a file `./src/test.lua`, executing `stylua src/` will look for `./src/stylua.toml` and then `./stylua.toml`. ### Fixed diff --git a/README.md b/README.md index 7569775c..51e46310 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,8 @@ StyLua has opinionated defaults, but also provides a few options that can be set ### Finding the configuration -The CLI looks for `stylua.toml` or `.stylua.toml` in the directory where the tool was executed. +The CLI looks for a `stylua.toml` or `.stylua.toml` starting from the directory of the file being formatted. +It will keep searching upwards until it reaches the current directory where the tool was executed. If not found, we search for an `.editorconfig` file, otherwise fall back to the default configuration. This feature can be disabled using `--no-editorconfig`. See [EditorConfig](https://editorconfig.org/) for more details. diff --git a/src/cli/config.rs b/src/cli/config.rs index 5c19864b..48e3b861 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1,12 +1,16 @@ use crate::opt::Opt; use anyhow::{Context, Result}; use log::*; +use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; use stylua_lib::Config; use stylua_lib::SortRequiresConfig; +#[cfg(feature = "editorconfig")] +use stylua_lib::editorconfig; + static CONFIG_FILE_NAME: [&str; 2] = ["stylua.toml", ".stylua.toml"]; fn read_config_file(path: &Path) -> Result { @@ -16,144 +20,217 @@ fn read_config_file(path: &Path) -> Result { Ok(config) } -/// Searches the directory for the configuration toml file (i.e. `stylua.toml` or `.stylua.toml`) -fn find_toml_file(directory: &Path) -> Option { - for name in &CONFIG_FILE_NAME { - let file_path = directory.join(name); - if file_path.exists() { - return Some(file_path); - } - } +fn read_and_apply_overrides(path: &Path, opt: &Opt) -> Result { + read_config_file(path).map(|config| load_overrides(config, opt)) +} - None +pub struct ConfigResolver<'a> { + config_cache: HashMap>, + forced_configuration: Option, + current_directory: PathBuf, + default_configuration: Config, + opt: &'a Opt, } -/// Looks for a configuration file in the directory provided (and its parent's recursively, if specified) -fn find_config_file(mut directory: PathBuf, recursive: bool) -> Result> { - debug!("config: looking for config in {}", directory.display()); - let config_file = find_toml_file(&directory); - match config_file { - Some(file_path) => { - debug!("config: found config at {}", file_path.display()); - read_config_file(&file_path).map(Some) +impl ConfigResolver<'_> { + pub fn new(opt: &Opt) -> Result { + let forced_configuration = opt + .config_path + .as_ref() + .map(|config_path| { + debug!( + "config: explicit config path provided at {}", + config_path.display() + ); + read_and_apply_overrides(config_path, opt) + }) + .transpose()?; + + Ok(ConfigResolver { + config_cache: HashMap::new(), + forced_configuration, + current_directory: env::current_dir().context("Could not find current directory")?, + default_configuration: load_overrides(Config::default(), opt), + opt, + }) + } + + pub fn load_configuration(&mut self, path: &Path) -> Result { + if let Some(configuration) = self.forced_configuration { + return Ok(configuration); } - None => { - // Both don't exist, search up the tree if necessary - // directory.pop() mutates the path to get its parent, and returns false if no more parent - if recursive && directory.pop() { - find_config_file(directory, recursive) - } else { - Ok(None) + + let root = match self.opt.search_parent_directories { + true => None, + false => Some(self.current_directory.to_path_buf()), + }; + + let absolute_path = self.current_directory.join(path); + let parent_path = &absolute_path + .parent() + .with_context(|| format!("no parent directory found for {}", path.display()))?; + + match self.find_config_file(parent_path, root)? { + Some(config) => Ok(config), + None => { + #[cfg(feature = "editorconfig")] + if self.opt.no_editorconfig { + Ok(self.default_configuration) + } else { + editorconfig::parse(self.default_configuration, path) + .context("could not parse editorconfig") + } + #[cfg(not(feature = "editorconfig"))] + Ok(self.default_configuration) } } } -} -pub fn find_ignore_file_path(mut directory: PathBuf, recursive: bool) -> Option { - debug!("config: looking for ignore file in {}", directory.display()); - let file_path = directory.join(".styluaignore"); - if file_path.is_file() { - Some(file_path) - } else if recursive && directory.pop() { - find_ignore_file_path(directory, recursive) - } else { - None - } -} + pub fn load_configuration_for_stdin(&mut self) -> Result { + if let Some(configuration) = self.forced_configuration { + return Ok(configuration); + } -/// Looks for a configuration file at either `$XDG_CONFIG_HOME`, `$XDG_CONFIG_HOME/stylua`, `$HOME/.config` or `$HOME/.config/stylua` -fn search_config_locations() -> Result> { - // Look in `$XDG_CONFIG_HOME` - if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") { - let xdg_config_path = Path::new(&xdg_config); - if xdg_config_path.exists() { - debug!("config: looking in $XDG_CONFIG_HOME"); + match &self.opt.stdin_filepath { + Some(filepath) => self.load_configuration(filepath), + None => { + #[cfg(feature = "editorconfig")] + if self.opt.no_editorconfig { + Ok(self.default_configuration) + } else { + editorconfig::parse(self.default_configuration, &PathBuf::from("*.lua")) + .context("could not parse editorconfig") + } + #[cfg(not(feature = "editorconfig"))] + Ok(self.default_configuration) + } + } + } - if let Some(config) = find_config_file(xdg_config_path.to_path_buf(), false)? { - return Ok(Some(config)); + fn lookup_config_file_in_directory(&self, directory: &Path) -> Result> { + debug!("config: looking for config in {}", directory.display()); + let config_file = find_toml_file(directory); + match config_file { + Some(file_path) => { + debug!("config: found config at {}", file_path.display()); + read_config_file(&file_path).map(Some) } + None => Ok(None), + } + } - debug!("config: looking in $XDG_CONFIG_HOME/stylua"); - let xdg_config_path = xdg_config_path.join("stylua"); - if xdg_config_path.exists() { - if let Some(config) = find_config_file(xdg_config_path, false)? { - return Ok(Some(config)); + /// Looks for a configuration file in the directory provided + /// Keep searching recursively upwards until we hit the root (if provided), then stop + /// When `--search-parent-directories` is enabled, root = None, else root = Some(cwd) + fn find_config_file( + &mut self, + directory: &Path, + root: Option, + ) -> Result> { + if let Some(config) = self.config_cache.get(directory) { + return Ok(*config); + } + + let resolved_configuration = match self.lookup_config_file_in_directory(directory)? { + Some(config) => Some(config), + None => { + let parent_directory = directory.parent(); + let should_stop = Some(directory) == root.as_deref() || parent_directory.is_none(); + + if should_stop { + debug!("config: no configuration file found"); + if self.opt.search_parent_directories { + if let Some(config) = self.search_config_locations()? { + return Ok(Some(config)); + } + } + + debug!("config: falling back to default config"); + None + } else { + self.find_config_file(parent_directory.unwrap(), root)? } } - } + }; + + self.config_cache + .insert(directory.to_path_buf(), resolved_configuration); + Ok(resolved_configuration) } - // Look in `$HOME/.config` - if let Ok(home) = std::env::var("HOME") { - let home_config_path = Path::new(&home).join(".config"); + /// Looks for a configuration file at either `$XDG_CONFIG_HOME`, `$XDG_CONFIG_HOME/stylua`, `$HOME/.config` or `$HOME/.config/stylua` + fn search_config_locations(&self) -> Result> { + // Look in `$XDG_CONFIG_HOME` + if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") { + let xdg_config_path = Path::new(&xdg_config); + if xdg_config_path.exists() { + debug!("config: looking in $XDG_CONFIG_HOME"); - if home_config_path.exists() { - debug!("config: looking in $HOME/.config"); + if let Some(config) = self.lookup_config_file_in_directory(xdg_config_path)? { + return Ok(Some(config)); + } - if let Some(config) = find_config_file(home_config_path.to_owned(), false)? { - return Ok(Some(config)); + debug!("config: looking in $XDG_CONFIG_HOME/stylua"); + let xdg_config_path = xdg_config_path.join("stylua"); + if xdg_config_path.exists() { + if let Some(config) = self.lookup_config_file_in_directory(&xdg_config_path)? { + return Ok(Some(config)); + } + } } + } + + // Look in `$HOME/.config` + if let Ok(home) = std::env::var("HOME") { + let home_config_path = Path::new(&home).join(".config"); - debug!("config: looking in $HOME/.config/stylua"); - let home_config_path = home_config_path.join("stylua"); if home_config_path.exists() { - if let Some(config) = find_config_file(home_config_path, false)? { + debug!("config: looking in $HOME/.config"); + + if let Some(config) = self.lookup_config_file_in_directory(&home_config_path)? { return Ok(Some(config)); } + + debug!("config: looking in $HOME/.config/stylua"); + let home_config_path = home_config_path.join("stylua"); + if home_config_path.exists() { + if let Some(config) = self.lookup_config_file_in_directory(&home_config_path)? { + return Ok(Some(config)); + } + } } } - } - Ok(None) + Ok(None) + } } -pub fn load_config(opt: &Opt) -> Result> { - match &opt.config_path { - Some(config_path) => { - debug!( - "config: explicit config path provided at {}", - config_path.display() - ); - read_config_file(config_path).map(Some) +/// Searches the directory for the configuration toml file (i.e. `stylua.toml` or `.stylua.toml`) +fn find_toml_file(directory: &Path) -> Option { + for name in &CONFIG_FILE_NAME { + let file_path = directory.join(name); + if file_path.exists() { + return Some(file_path); } - None => { - let current_dir = match &opt.stdin_filepath { - Some(file_path) => file_path - .parent() - .context("Could not find current directory from provided stdin filepath")? - .to_path_buf(), - None => env::current_dir().context("Could not find current directory")?, - }; - - debug!( - "config: starting config search from {} - recursively searching parents: {}", - current_dir.display(), - opt.search_parent_directories - ); - let config = find_config_file(current_dir, opt.search_parent_directories)?; - match config { - Some(config) => Ok(Some(config)), - None => { - debug!("config: no configuration file found"); + } - // Search the configuration directory for a file, if necessary - if opt.search_parent_directories { - if let Some(config) = search_config_locations()? { - return Ok(Some(config)); - } - } + None +} - // Fallback to a default configuration - debug!("config: falling back to default config"); - Ok(None) - } - } - } +pub fn find_ignore_file_path(mut directory: PathBuf, recursive: bool) -> Option { + debug!("config: looking for ignore file in {}", directory.display()); + let file_path = directory.join(".styluaignore"); + if file_path.is_file() { + Some(file_path) + } else if recursive && directory.pop() { + find_ignore_file_path(directory, recursive) + } else { + None } } /// Handles any overrides provided by command line options -pub fn load_overrides(config: Config, opt: &Opt) -> Config { +fn load_overrides(config: Config, opt: &Opt) -> Config { let mut new_config = config; if let Some(syntax) = opt.format_opts.syntax { diff --git a/src/cli/main.rs b/src/cli/main.rs index 2c96d7ce..045388cd 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -8,16 +8,12 @@ use std::collections::HashSet; use std::fs; use std::io::{stderr, stdin, stdout, Read, Write}; use std::path::Path; -#[cfg(feature = "editorconfig")] -use std::path::PathBuf; use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; use std::sync::Arc; use std::time::Instant; use thiserror::Error; use threadpool::ThreadPool; -#[cfg(feature = "editorconfig")] -use stylua_lib::editorconfig; use stylua_lib::{format_code, Config, OutputVerification, Range}; use crate::config::find_ignore_file_path; @@ -272,14 +268,11 @@ fn format(opt: opt::Opt) -> Result { } // Load the configuration - let loaded = config::load_config(&opt)?; - #[cfg(feature = "editorconfig")] - let is_default_config = loaded.is_none(); - let config = loaded.unwrap_or_default(); + let opt_for_config_resolver = opt.clone(); + let mut config_resolver = config::ConfigResolver::new(&opt_for_config_resolver)?; - // Handle any configuration overrides provided by options - let config = config::load_overrides(config, &opt); - debug!("config: {:#?}", config); + // TODO: + // debug!("config: {:#?}", config); // Create range if provided let range = if opt.range_start.is_some() || opt.range_end.is_some() { @@ -425,40 +418,24 @@ fn format(opt: opt::Opt) -> Result { None => false, }; - pool.execute(move || { - #[cfg(not(feature = "editorconfig"))] - let used_config = Ok(config); - #[cfg(feature = "editorconfig")] - let used_config = match is_default_config && !&opt.no_editorconfig { - true => { - let path = match &opt.stdin_filepath { - Some(filepath) => filepath.to_path_buf(), - None => PathBuf::from("*.lua"), - }; - editorconfig::parse(config, &path) - .context("could not parse editorconfig") - } - false => Ok(config), - }; + let config = config_resolver.load_configuration_for_stdin()?; + pool.execute(move || { let mut buf = String::new(); tx.send( - used_config - .and_then(|used_config| { - stdin() - .read_to_string(&mut buf) - .map_err(|err| err.into()) - .and_then(|_| { - format_string( - buf, - used_config, - range, - &opt, - verify_output, - should_skip_format, - ) - .context("could not format from stdin") - }) + stdin() + .read_to_string(&mut buf) + .map_err(|err| err.into()) + .and_then(|_| { + format_string( + buf, + config, + range, + &opt, + verify_output, + should_skip_format, + ) + .context("could not format from stdin") }) .map_err(|error| { ErrorFileWrapper { @@ -506,29 +483,20 @@ fn format(opt: opt::Opt) -> Result { continue; } + let config = config_resolver.load_configuration(&path)?; + let tx = tx.clone(); pool.execute(move || { - #[cfg(not(feature = "editorconfig"))] - let used_config = Ok(config); - #[cfg(feature = "editorconfig")] - let used_config = match is_default_config && !&opt.no_editorconfig { - true => editorconfig::parse(config, &path) - .context("could not parse editorconfig"), - false => Ok(config), - }; - tx.send( - used_config - .and_then(|used_config| { - format_file(&path, used_config, range, &opt, verify_output) - }) - .map_err(|error| { + format_file(&path, config, range, &opt, verify_output).map_err( + |error| { ErrorFileWrapper { file: path.display().to_string(), error, } .into() - }), + }, + ), ) .unwrap() }); @@ -808,4 +776,181 @@ mod tests { cwd.close().unwrap(); } + + #[test] + fn test_stdin_filepath_respects_cwd_configuration_next_to_file() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .args(["--stdin-filepath", "foo.lua", "-"]) + .write_stdin("local x = \"hello\"") + .assert() + .success() + .stdout("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_stdin_filepath_respects_cwd_configuration_for_nested_file() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .args(["--stdin-filepath", "build/foo.lua", "-"]) + .write_stdin("local x = \"hello\"") + .assert() + .success() + .stdout("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_cwd_configuration_respected_for_file_in_cwd() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .arg("foo.lua") + .assert() + .success(); + + cwd.child("foo.lua").assert("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_cwd_configuration_respected_for_nested_file() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .arg("build/foo.lua") + .assert() + .success(); + + cwd.child("build/foo.lua").assert("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_configuration_is_not_used_outside_of_cwd() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.child("build").path()) + .arg("foo.lua") + .assert() + .success(); + + cwd.child("build/foo.lua").assert("local x = \"hello\"\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_configuration_used_outside_of_cwd_when_search_parent_directories_is_enabled() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.child("build").path()) + .args(["--search-parent-directories", "foo.lua"]) + .assert() + .success(); + + cwd.child("build/foo.lua").assert("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_configuration_is_searched_next_to_file() { + let cwd = construct_tree!({ + "build/stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .arg("build/foo.lua") + .assert() + .success(); + + cwd.child("build/foo.lua").assert("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_configuration_is_used_closest_to_the_file() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferDouble'", + "build/stylua.toml": "quote_style = 'AutoPreferSingle'", + "build/foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .arg("build/foo.lua") + .assert() + .success(); + + cwd.child("build/foo.lua").assert("local x = 'hello'\n"); + + cwd.close().unwrap(); + } + + #[test] + fn test_respect_config_path_override() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferDouble'", + "build/stylua.toml": "quote_style = 'AutoPreferSingle'", + "foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .args(["--config-path", "build/stylua.toml", "foo.lua"]) + .assert() + .success(); + } + + #[test] + fn test_respect_config_path_override_for_stdin_filepath() { + let cwd = construct_tree!({ + "stylua.toml": "quote_style = 'AutoPreferDouble'", + "build/stylua.toml": "quote_style = 'AutoPreferSingle'", + "foo.lua": "local x = \"hello\"", + }); + + let mut cmd = create_stylua(); + cmd.current_dir(cwd.path()) + .args(["--config-path", "build/stylua.toml", "-"]) + .write_stdin("local x = \"hello\"") + .assert() + .success() + .stdout("local x = 'hello'\n"); + + cwd.close().unwrap(); + } } diff --git a/src/cli/opt.rs b/src/cli/opt.rs index 0e7c5884..3769317f 100644 --- a/src/cli/opt.rs +++ b/src/cli/opt.rs @@ -9,7 +9,7 @@ lazy_static::lazy_static! { static ref NUM_CPUS: String = num_cpus::get().to_string(); } -#[derive(StructOpt, Debug)] +#[derive(StructOpt, Clone, Debug)] #[structopt(name = "stylua", about = "A utility to format Lua code", version)] pub struct Opt { /// Specify path to stylua.toml configuration file. @@ -160,7 +160,7 @@ pub enum OutputFormat { Summary, } -#[derive(StructOpt, Debug)] +#[derive(StructOpt, Clone, Copy, Debug)] pub struct FormatOpts { /// The type of Lua syntax to parse #[structopt(long, arg_enum, ignore_case = true)]