diff --git a/Cargo.lock b/Cargo.lock index 226e1976..e3b29522 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,7 @@ dependencies = [ "thiserror 2.0.5", "tokio", "tracing", + "workspace", ] [[package]] @@ -328,6 +329,8 @@ dependencies = [ "drop_bomb", "indexmap", "rustc-hash", + "schemars", + "serde", "tracing", "unicode-width", ] @@ -1377,6 +1380,12 @@ dependencies = [ "url", ] +[[package]] +name = "matchit" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0aa4b8ca861b08d68afc8702af3250776898c1508b278e1da9d01e01d4b45c" + [[package]] name = "memchr" version = "2.7.4" @@ -1511,6 +1520,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "path-slash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1670,9 +1685,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" [[package]] name = "rustix" @@ -1847,6 +1862,15 @@ dependencies = [ "syn 2.0.90", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2201,6 +2225,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -2635,6 +2693,36 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "workspace" +version = "0.1.0" +dependencies = [ + "air_r_formatter", + "anyhow", + "biome_formatter", + "fs", + "ignore", + "insta", + "line_ending", + "matchit", + "path-absolutize", + "path-slash", + "rustc-hash", + "serde", + "tempfile", + "thiserror 2.0.5", + "toml", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index b6b56566..12df6364 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ line_ending = { path = "./crates/line_ending" } lsp = { path = "./crates/lsp" } lsp_test = { path = "./crates/lsp_test" } tests_macros = { path = "./crates/tests_macros" } +workspace = { path = "./crates/workspace" } anyhow = "1.0.89" assert_matches = "1.5.0" @@ -56,17 +57,22 @@ ignore = "0.4.23" insta = "1.40.0" itertools = "0.13.0" line-index = "0.1.2" +matchit = "0.8.5" memchr = "2.7.4" path-absolutize = "3.1.1" +path-slash = "0.2.1" proc-macro2 = "1.0.86" -serde = { version = "1.0.215", features = ["derive"] } +rustc-hash = "2.1.0" +serde = "1.0.215" serde_json = "1.0.132" struct-field-names-as-array = "0.3.0" strum = "0.26" +tempfile = "3.9.0" time = "0.3.37" thiserror = "2.0.5" tokio = { version = "1.41.1" } tokio-util = "0.7.12" +toml = "0.8.19" # For https://github.com/ebkalderon/tower-lsp/pull/428 tower-lsp = { git = "https://github.com/lionel-/tower-lsp", branch = "bugfix/patches" } tracing = { version = "0.1.40", default-features = false, features = ["std"] } diff --git a/crates/air/Cargo.toml b/crates/air/Cargo.toml index 4df75281..9736f92b 100644 --- a/crates/air/Cargo.toml +++ b/crates/air/Cargo.toml @@ -28,9 +28,10 @@ lsp = { workspace = true } thiserror = { workspace = true } tokio = "1.41.1" tracing = { workspace = true } +workspace = { workspace = true } [dev-dependencies] -tempfile = "3.9.0" +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/air/src/commands/format.rs b/crates/air/src/commands/format.rs index 46c127a6..6787bb55 100644 --- a/crates/air/src/commands/format.rs +++ b/crates/air/src/commands/format.rs @@ -8,23 +8,26 @@ use std::path::PathBuf; use air_r_formatter::context::RFormatOptions; use air_r_parser::RParserOptions; use fs::relativize_path; -use ignore::DirEntry; use itertools::Either; use itertools::Itertools; -use line_ending::LineEnding; use thiserror::Error; +use workspace::resolve::resolve_paths; +use workspace::settings::FormatSettings; use crate::args::FormatCommand; use crate::ExitStatus; pub(crate) fn format(command: FormatCommand) -> anyhow::Result { let mode = FormatMode::from_command(&command); - let paths = resolve_paths(&command.paths); + let (paths, resolver) = resolve_paths(&command.paths)?; let (actions, errors): (Vec<_>, Vec<_>) = paths .into_iter() .map(|path| match path { - Ok(path) => format_file(path, mode), + Ok(path) => { + let settings = resolver.resolve(&path); + format_file(path, mode, &settings.format) + } Err(err) => Err(err.into()), }) .partition_map(|result| match result { @@ -99,62 +102,6 @@ fn write_changed(actions: &[FormatFileAction], f: &mut impl Write) -> io::Result Ok(()) } -fn resolve_paths(paths: &[PathBuf]) -> Vec> { - let paths: Vec = paths.iter().map(fs::normalize_path).collect(); - - let (first_path, paths) = paths - .split_first() - .expect("Clap should ensure at least 1 path is supplied."); - - // TODO: Parallel directory visitor - let mut builder = ignore::WalkBuilder::new(first_path); - - for path in paths { - builder.add(path); - } - - let mut out = Vec::new(); - - for path in builder.build() { - match path { - Ok(entry) => { - if let Some(path) = is_valid_path(entry) { - out.push(Ok(path)); - } - } - Err(err) => { - out.push(Err(err)); - } - } - } - - out -} - -// Decide whether or not to accept an `entry` based on include/exclude rules. -fn is_valid_path(entry: DirEntry) -> Option { - // Ignore directories - if entry.file_type().map_or(true, |ft| ft.is_dir()) { - return None; - } - - // Accept all files that are passed-in directly, even non-R files - if entry.depth() == 0 { - let path = entry.into_path(); - return Some(path); - } - - // Otherwise check if we should accept this entry - // TODO: Many other checks based on user exclude/includes - let path = entry.into_path(); - - if !fs::has_r_extension(&path) { - return None; - } - - Some(path) -} - pub(crate) enum FormatFileAction { Formatted(PathBuf), Unchanged, @@ -166,18 +113,15 @@ impl FormatFileAction { } } -// TODO: Take workspace `FormatOptions` that get resolved to `RFormatOptions` -// for the formatter here. Respect user specified `LineEnding` option too, and -// only use inferred endings when `FormatOptions::LineEnding::Auto` is used. -fn format_file(path: PathBuf, mode: FormatMode) -> Result { +fn format_file( + path: PathBuf, + mode: FormatMode, + settings: &FormatSettings, +) -> Result { let source = std::fs::read_to_string(&path) .map_err(|err| FormatCommandError::Read(path.clone(), err))?; - let line_ending = match line_ending::infer(&source) { - LineEnding::Lf => biome_formatter::LineEnding::Lf, - LineEnding::Crlf => biome_formatter::LineEnding::Crlf, - }; - let options = RFormatOptions::default().with_line_ending(line_ending); + let options = settings.to_format_options(&source); let source = line_ending::normalize(source); let formatted = match format_source(source.as_str(), options) { diff --git a/crates/air_r_formatter/src/context.rs b/crates/air_r_formatter/src/context.rs index 84acba35..a4956636 100644 --- a/crates/air_r_formatter/src/context.rs +++ b/crates/air_r_formatter/src/context.rs @@ -17,6 +17,7 @@ use biome_formatter::TransformSourceMap; use crate::comments::FormatRLeadingComment; use crate::comments::RCommentStyle; use crate::comments::RComments; +use crate::options::MagicLineBreak; pub struct RFormatContext { options: RFormatOptions, @@ -77,6 +78,10 @@ pub struct RFormatOptions { /// The max width of a line. Defaults to 80. line_width: LineWidth, + + // TODO: Actually use this internally! + /// The behavior of magic line breaks. + magic_line_break: MagicLineBreak, } impl RFormatOptions { @@ -106,6 +111,11 @@ impl RFormatOptions { self } + pub fn with_magic_line_break(mut self, magic_line_break: MagicLineBreak) -> Self { + self.magic_line_break = magic_line_break; + self + } + pub fn set_indent_style(&mut self, indent_style: IndentStyle) { self.indent_style = indent_style; } @@ -121,6 +131,10 @@ impl RFormatOptions { pub fn set_line_width(&mut self, line_width: LineWidth) { self.line_width = line_width; } + + pub fn set_magic_line_break(&mut self, magic_line_break: MagicLineBreak) { + self.magic_line_break = magic_line_break; + } } impl FormatOptions for RFormatOptions { diff --git a/crates/air_r_formatter/src/lib.rs b/crates/air_r_formatter/src/lib.rs index 93fff37f..760efe64 100644 --- a/crates/air_r_formatter/src/lib.rs +++ b/crates/air_r_formatter/src/lib.rs @@ -21,6 +21,7 @@ use crate::cst::FormatRSyntaxNode; pub mod comments; pub mod context; mod cst; +pub mod options; mod prelude; mod r; pub(crate) mod separated; diff --git a/crates/air_r_formatter/src/options.rs b/crates/air_r_formatter/src/options.rs new file mode 100644 index 00000000..7c04088a --- /dev/null +++ b/crates/air_r_formatter/src/options.rs @@ -0,0 +1,3 @@ +mod magic_line_break; + +pub use magic_line_break::*; diff --git a/crates/air_r_formatter/src/options/magic_line_break.rs b/crates/air_r_formatter/src/options/magic_line_break.rs new file mode 100644 index 00000000..ec331bf5 --- /dev/null +++ b/crates/air_r_formatter/src/options/magic_line_break.rs @@ -0,0 +1,44 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +pub enum MagicLineBreak { + /// Respect + #[default] + Respect, + /// Ignore + Ignore, +} + +impl MagicLineBreak { + /// Returns `true` if magic line breaks should be respected. + pub const fn is_respect(&self) -> bool { + matches!(self, MagicLineBreak::Respect) + } + + /// Returns `true` if magic line breaks should be ignored. + pub const fn is_ignore(&self) -> bool { + matches!(self, MagicLineBreak::Ignore) + } +} + +impl FromStr for MagicLineBreak { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "respect" => Ok(Self::Respect), + "ignore" => Ok(Self::Ignore), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for MagicLineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MagicLineBreak::Respect => std::write!(f, "Respect"), + MagicLineBreak::Ignore => std::write!(f, "Ignore"), + } + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml new file mode 100644 index 00000000..fafb65de --- /dev/null +++ b/crates/workspace/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "workspace" +version = "0.1.0" +publish = false +authors.workspace = true +categories.workspace = true +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +air_r_formatter = { workspace = true } +biome_formatter = { workspace = true, features = ["serde"] } +fs = { workspace = true } +ignore = { workspace = true } +line_ending = { workspace = true } +matchit = { workspace = true } +path-absolutize = { workspace = true } +path-slash = { workspace = true } +rustc-hash = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +insta = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs new file mode 100644 index 00000000..cb9835a0 --- /dev/null +++ b/crates/workspace/src/lib.rs @@ -0,0 +1,4 @@ +pub mod resolve; +pub mod settings; +pub mod toml; +pub mod toml_options; diff --git a/crates/workspace/src/resolve.rs b/crates/workspace/src/resolve.rs new file mode 100644 index 00000000..e615b9b4 --- /dev/null +++ b/crates/workspace/src/resolve.rs @@ -0,0 +1,192 @@ +// --- source +// authors = ["Charlie Marsh"] +// license = "MIT" +// origin = "https://github.com/astral-sh/ruff/tree/main/crates/ruff_workspace" +// --- + +use std::path::{Path, PathBuf}; + +use ignore::DirEntry; +use matchit::{InsertError, Match, Router}; +use path_slash::PathExt; +use rustc_hash::FxHashSet; +use thiserror::Error; + +use crate::settings::Settings; +use crate::toml::find_air_toml_in_directory; +use crate::toml::parse_air_toml; +use crate::toml::ParseTomlError; + +/// Resolves a given [`Path`] to its [`Settings`] +/// +/// You build a [`Resolver`] by calling [`Resolver::add`] on each directory that the +/// resolver is aware of, associating that directory with an instance of [`Settings`]. +/// +/// You can then call [`Resolver::resolve`] on any individual path nested within one of +/// those directories to "look up" its corresponding [`Settings`]. +/// +/// The core of it works using a very fast URL [`Router`]. In [`Resolver::add`] we insert +/// `dir/path/{*filepath}` into the router to match any path under `dir/path/`, which is +/// then matched against in [`Resolver::resolve`]. +#[derive(Debug, Default)] +pub struct Resolver { + // TODO: These `root_settings` should probably be passed in to `new()` and + // should probably be determined from the workspace? + /// Root [`Settings`] to be used when no `air.toml` is associated with a path. + root_settings: Settings, + /// All [`Settings`] that have been added to the resolver. + settings: Vec, + /// A router from path to index into the `settings` vector. + router: Router, +} + +impl Resolver { + /// Create a new [`Resolver`] + pub fn new() -> Self { + Self::default() + } + + /// Add a resolved [`Settings`] under a given [`PathBuf`] scope. + pub fn add(&mut self, path: &Path, settings: Settings) { + self.settings.push(settings); + + // Normalize the path to use `/` separators and escape the '{' and '}' characters, + // which matchit uses for routing parameters. + let path = path.to_slash_lossy().replace('{', "{{").replace('}', "}}"); + + // Insert a mapping that matches any path under this directory + match self + .router + .insert(format!("{path}/{{*filepath}}"), self.settings.len() - 1) + { + Ok(()) => {} + Err(InsertError::Conflict { .. }) => { + return; + } + Err(_) => unreachable!("File paths are escaped before being inserted in the router"), + } + + // Insert a mapping that matches the directory itself (without a trailing slash). + // Inserting should always succeed because conflicts are resolved above and the above insertion guarantees + // that the path is correctly escaped. + self.router.insert(path, self.settings.len() - 1).unwrap(); + } + + /// Return the appropriate [`Settings`] for a given [`Path`]. + pub fn resolve(&self, path: &Path) -> &Settings { + self.router + .at(path.to_slash_lossy().as_ref()) + .map(|Match { value, .. }| &self.settings[*value]) + .unwrap_or(&self.root_settings) + } +} + +/// Parse [Settings] from a given `air.toml` +// TODO: Allow for an `extends` option in `air.toml`, which will make things more complex, +// but will be very useful once we support hierarchical configuration as a way of +// "inheriting" most top level configuration while slightly tweaking it in a nested directory. +fn parse_settings(toml: &Path) -> Result { + let options = parse_air_toml(toml)?; + let settings = options.into_settings(); + Ok(settings) +} + +#[derive(Debug, Error)] +pub enum ResolvePathsError { + #[error(transparent)] + ParseToml(#[from] ParseTomlError), +} + +/// For each provided `path`: +/// - Collect the [`Settings`] associated with that `path` and store in the [`Resolver`] +/// - Recursively search for any R files that match that `path` +pub fn resolve_paths( + paths: &[PathBuf], +) -> Result<(Vec>, Resolver), ResolvePathsError> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + let mut resolver = Resolver::new(); + let mut seen = FxHashSet::default(); + + // Load the `resolver` with `Settings` associated with each `path` + for path in &paths { + for ancestor in path.ancestors() { + if seen.insert(ancestor) { + if let Some(toml) = find_air_toml_in_directory(ancestor) { + let settings = parse_settings(&toml)?; + resolver.add(ancestor, settings); + break; + } + } else { + // We already visited this ancestor, we can stop here. + break; + } + } + } + + let (first_path, paths) = paths + .split_first() + .expect("Clap should ensure at least 1 path is supplied."); + + // TODO: Parallel directory visitor + let mut builder = ignore::WalkBuilder::new(first_path); + + for path in paths { + builder.add(path); + } + + // TODO: Make these configurable options (possibly just one?) + // Right now we explicitly call them even though they are `true` by default + // to remind us to expose them. + // + // "This toggles, as a group, all the filters that are enabled by default" + // builder.standard_filters(true) + builder.hidden(true); + builder.parents(true); + builder.ignore(true); + builder.git_ignore(true); + builder.git_global(true); + builder.git_exclude(true); + + let mut out = Vec::new(); + + // Walk all `paths` recursively, collecting R files that we can format + for path in builder.build() { + match path { + Ok(entry) => { + if let Some(path) = is_match(entry) { + out.push(Ok(path)); + } + } + Err(err) => { + out.push(Err(err)); + } + } + } + + Ok((out, resolver)) +} + +// Decide whether or not to accept an `entry` based on include/exclude rules. +fn is_match(entry: DirEntry) -> Option { + // Ignore directories + if entry.file_type().map_or(true, |ft| ft.is_dir()) { + return None; + } + + // Accept all files that are passed-in directly, even non-R files + if entry.depth() == 0 { + let path = entry.into_path(); + return Some(path); + } + + // Otherwise check if we should accept this entry + // TODO: Many other checks based on user exclude/includes + let path = entry.into_path(); + + if !fs::has_r_extension(&path) { + return None; + } + + Some(path) +} diff --git a/crates/workspace/src/settings.rs b/crates/workspace/src/settings.rs new file mode 100644 index 00000000..dec96df6 --- /dev/null +++ b/crates/workspace/src/settings.rs @@ -0,0 +1,60 @@ +mod indent_style; +mod indent_width; +// TODO: Can we pick a better crate name for `line_ending` so these don't collide? +#[path = "settings/line_ending.rs"] +mod line_ending_setting; +mod line_length; +mod magic_line_break; + +pub use indent_style::*; +pub use indent_width::*; +pub use line_ending_setting::*; +pub use line_length::*; +pub use magic_line_break::*; + +use air_r_formatter::context::RFormatOptions; +use line_ending; + +/// Resolved configuration settings used within air +/// +/// May still require a source document to finalize some options, such as +/// `LineEnding::Auto` in the formatter. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct Settings { + /// Settings to configure code formatting. + pub format: FormatSettings, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct FormatSettings { + pub indent_style: IndentStyle, + pub indent_width: IndentWidth, + pub line_ending: LineEnding, + pub line_length: LineLength, + pub magic_line_break: MagicLineBreak, +} + +impl FormatSettings { + // Finalize `RFormatOptions` in preparation for a formatting operation on `source` + pub fn to_format_options(&self, source: &str) -> RFormatOptions { + let line_ending = match self.line_ending { + LineEnding::Lf => biome_formatter::LineEnding::Lf, + LineEnding::Crlf => biome_formatter::LineEnding::Crlf, + #[cfg(target_os = "windows")] + LineEnding::Native => biome_formatter::LineEnding::Crlf, + #[cfg(not(target_os = "windows"))] + LineEnding::Native => biome_formatter::LineEnding::Lf, + LineEnding::Auto => match line_ending::infer(source) { + line_ending::LineEnding::Lf => biome_formatter::LineEnding::Lf, + line_ending::LineEnding::Crlf => biome_formatter::LineEnding::Crlf, + }, + }; + + RFormatOptions::new() + .with_indent_style(self.indent_style.into()) + .with_indent_width(self.indent_width.into()) + .with_line_ending(line_ending) + .with_line_width(self.line_length.into()) + .with_magic_line_break(self.magic_line_break.into()) + } +} diff --git a/crates/workspace/src/settings/indent_style.rs b/crates/workspace/src/settings/indent_style.rs new file mode 100644 index 00000000..f84d94da --- /dev/null +++ b/crates/workspace/src/settings/indent_style.rs @@ -0,0 +1,54 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum IndentStyle { + /// Tab + #[default] + Tab, + /// Space + Space, +} + +impl IndentStyle { + /// Returns `true` if this is an [IndentStyle::Tab]. + pub const fn is_tab(&self) -> bool { + matches!(self, IndentStyle::Tab) + } + + /// Returns `true` if this is an [IndentStyle::Space]. + pub const fn is_space(&self) -> bool { + matches!(self, IndentStyle::Space) + } +} + +impl FromStr for IndentStyle { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "tab" => Ok(Self::Tab), + "space" => Ok(Self::Space), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for IndentStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IndentStyle::Tab => std::write!(f, "Tab"), + IndentStyle::Space => std::write!(f, "Space"), + } + } +} + +impl From for biome_formatter::IndentStyle { + fn from(value: IndentStyle) -> Self { + match value { + IndentStyle::Tab => biome_formatter::IndentStyle::Tab, + IndentStyle::Space => biome_formatter::IndentStyle::Space, + } + } +} diff --git a/crates/workspace/src/settings/indent_width.rs b/crates/workspace/src/settings/indent_width.rs new file mode 100644 index 00000000..f9717faa --- /dev/null +++ b/crates/workspace/src/settings/indent_width.rs @@ -0,0 +1,148 @@ +use std::fmt; +use std::num::NonZeroU8; + +/// Validated value for the `indent-width` formatter options +/// +/// The allowed range of values is 1..=24 +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct IndentWidth(NonZeroU8); + +impl IndentWidth { + /// Maximum allowed value for a valid [IndentWidth] + const MAX: u8 = 24; + + /// Return the numeric value for this [IndentWidth] + pub fn value(&self) -> u8 { + self.0.get() + } +} + +impl Default for IndentWidth { + fn default() -> Self { + Self(NonZeroU8::new(4).unwrap()) + } +} + +impl std::fmt::Debug for IndentWidth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Debug::fmt(&self.0, f) + } +} + +impl std::fmt::Display for IndentWidth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl<'de> serde::Deserialize<'de> for IndentWidth { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: u8 = serde::Deserialize::deserialize(deserializer)?; + let indent_width = IndentWidth::try_from(value).map_err(serde::de::Error::custom)?; + Ok(indent_width) + } +} + +/// Error type returned when converting a u8 or NonZeroU8 to a [`IndentWidth`] fails +#[derive(Clone, Copy, Debug)] +pub struct IndentWidthFromIntError(u8); + +impl std::error::Error for IndentWidthFromIntError {} + +impl TryFrom for IndentWidth { + type Error = IndentWidthFromIntError; + + fn try_from(value: u8) -> Result { + match NonZeroU8::try_from(value) { + Ok(value) => IndentWidth::try_from(value), + Err(_) => Err(IndentWidthFromIntError(value)), + } + } +} + +impl TryFrom for IndentWidth { + type Error = IndentWidthFromIntError; + + fn try_from(value: NonZeroU8) -> Result { + if value.get() <= Self::MAX { + Ok(IndentWidth(value)) + } else { + Err(IndentWidthFromIntError(value.get())) + } + } +} + +impl std::fmt::Display for IndentWidthFromIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "The indent width must be a value between 1 and {max}, not {value}.", + max = IndentWidth::MAX, + value = self.0 + ) + } +} + +impl From for u8 { + fn from(value: IndentWidth) -> Self { + value.0.get() + } +} + +impl From for NonZeroU8 { + fn from(value: IndentWidth) -> Self { + value.0 + } +} + +impl From for biome_formatter::IndentWidth { + fn from(value: IndentWidth) -> Self { + // Unwrap: We assert that we match biome's `IndentWidth` perfectly + biome_formatter::IndentWidth::try_from(value.value()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use anyhow::Result; + + use crate::settings::IndentWidth; + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Options { + indent_width: Option, + } + + #[test] + fn deserialize_indent_width() -> Result<()> { + let options: Options = toml::from_str( + r" +indent-width = 2 +", + )?; + + assert_eq!( + options.indent_width, + Some(IndentWidth::try_from(2).unwrap()) + ); + + Ok(()) + } + + #[test] + fn deserialize_oob_indent_width() -> Result<()> { + let result: std::result::Result = toml::from_str( + r" +indent-width = 25 +", + ); + let error = result.err().context("Expected OOB `IndentWidth` error")?; + insta::assert_snapshot!(error); + Ok(()) + } +} diff --git a/crates/workspace/src/settings/line_ending.rs b/crates/workspace/src/settings/line_ending.rs new file mode 100644 index 00000000..b2bcf870 --- /dev/null +++ b/crates/workspace/src/settings/line_ending.rs @@ -0,0 +1,31 @@ +use std::fmt; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum LineEnding { + /// The newline style is detected automatically on a file per file basis. + /// Files with mixed line endings will be converted to the first detected line ending. + /// Defaults to [`LineEnding::Lf`] for a files that contain no line endings. + #[default] + Auto, + + /// Line endings will be converted to `\n` as is common on Unix. + Lf, + + /// Line endings will be converted to `\r\n` as is common on Windows. + Crlf, + + /// Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + Native, +} + +impl fmt::Display for LineEnding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => write!(f, "auto"), + Self::Lf => write!(f, "lf"), + Self::Crlf => write!(f, "crlf"), + Self::Native => write!(f, "native"), + } + } +} diff --git a/crates/workspace/src/settings/line_length.rs b/crates/workspace/src/settings/line_length.rs new file mode 100644 index 00000000..934ef64d --- /dev/null +++ b/crates/workspace/src/settings/line_length.rs @@ -0,0 +1,145 @@ +use std::fmt; +use std::num::NonZeroU16; + +/// Validated value for the `line-length` formatter options +/// +/// The allowed range of values is 1..=320 +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct LineLength(NonZeroU16); + +impl LineLength { + /// Maximum allowed value for a valid [LineLength] + const MAX: u16 = 320; + + /// Return the numeric value for this [LineLength] + pub fn value(&self) -> u16 { + self.0.get() + } +} + +impl Default for LineLength { + fn default() -> Self { + Self(NonZeroU16::new(80).unwrap()) + } +} + +impl std::fmt::Debug for LineLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + std::fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Display for LineLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl<'de> serde::Deserialize<'de> for LineLength { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value: u16 = serde::Deserialize::deserialize(deserializer)?; + let line_length = LineLength::try_from(value).map_err(serde::de::Error::custom)?; + Ok(line_length) + } +} + +/// Error type returned when converting a u16 or NonZeroU16 to a [`LineLength`] fails +#[derive(Clone, Copy, Debug)] +pub struct LineLengthFromIntError(u16); + +impl std::error::Error for LineLengthFromIntError {} + +impl TryFrom for LineLength { + type Error = LineLengthFromIntError; + + fn try_from(value: u16) -> Result { + match NonZeroU16::try_from(value) { + Ok(value) => LineLength::try_from(value), + Err(_) => Err(LineLengthFromIntError(value)), + } + } +} + +impl TryFrom for LineLength { + type Error = LineLengthFromIntError; + + fn try_from(value: NonZeroU16) -> Result { + if value.get() <= Self::MAX { + Ok(LineLength(value)) + } else { + Err(LineLengthFromIntError(value.get())) + } + } +} + +impl std::fmt::Display for LineLengthFromIntError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "The line length must be a value between 1 and {max}, not {value}.", + max = LineLength::MAX, + value = self.0 + ) + } +} + +impl From for u16 { + fn from(value: LineLength) -> Self { + value.0.get() + } +} + +impl From for NonZeroU16 { + fn from(value: LineLength) -> Self { + value.0 + } +} + +impl From for biome_formatter::LineWidth { + fn from(value: LineLength) -> Self { + // Unwrap: We assert that we match biome's `LineWidth` perfectly + biome_formatter::LineWidth::try_from(value.value()).unwrap() + } +} + +#[cfg(test)] +mod tests { + use anyhow::Context; + use anyhow::Result; + + use crate::settings::LineLength; + + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields, rename_all = "kebab-case")] + struct Options { + line_length: Option, + } + + #[test] + fn deserialize_line_length() -> Result<()> { + let options: Options = toml::from_str( + r" +line-length = 50 +", + )?; + + assert_eq!(options.line_length, Some(LineLength::try_from(50).unwrap())); + + Ok(()) + } + + #[test] + fn deserialize_oob_line_length() -> Result<()> { + let result: std::result::Result = toml::from_str( + r" +line-length = 400 +", + ); + let error = result.err().context("Expected OOB `LineLength` error")?; + insta::assert_snapshot!(error); + Ok(()) + } +} diff --git a/crates/workspace/src/settings/magic_line_break.rs b/crates/workspace/src/settings/magic_line_break.rs new file mode 100644 index 00000000..e6d26d27 --- /dev/null +++ b/crates/workspace/src/settings/magic_line_break.rs @@ -0,0 +1,53 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +pub enum MagicLineBreak { + /// Respect + #[default] + Respect, + /// Ignore + Ignore, +} + +impl MagicLineBreak { + /// Returns `true` if magic line breaks should be respected. + pub const fn is_respect(&self) -> bool { + matches!(self, MagicLineBreak::Respect) + } + + /// Returns `true` if magic line breaks should be ignored. + pub const fn is_ignore(&self) -> bool { + matches!(self, MagicLineBreak::Ignore) + } +} + +impl FromStr for MagicLineBreak { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "respect" => Ok(Self::Respect), + "ignore" => Ok(Self::Ignore), + _ => Err("Unsupported value for this option"), + } + } +} + +impl Display for MagicLineBreak { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MagicLineBreak::Respect => std::write!(f, "Respect"), + MagicLineBreak::Ignore => std::write!(f, "Ignore"), + } + } +} + +impl From for air_r_formatter::options::MagicLineBreak { + fn from(value: MagicLineBreak) -> Self { + match value { + MagicLineBreak::Respect => air_r_formatter::options::MagicLineBreak::Respect, + MagicLineBreak::Ignore => air_r_formatter::options::MagicLineBreak::Ignore, + } + } +} diff --git a/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap b/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap new file mode 100644 index 00000000..dad86226 --- /dev/null +++ b/crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap @@ -0,0 +1,9 @@ +--- +source: crates/workspace/src/settings/indent_width.rs +expression: error +--- +TOML parse error at line 2, column 16 + | +2 | indent-width = 25 + | ^^ +The indent width must be a value between 1 and 24, not 25. diff --git a/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap b/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap new file mode 100644 index 00000000..9570385c --- /dev/null +++ b/crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap @@ -0,0 +1,9 @@ +--- +source: crates/workspace/src/settings/line_length.rs +expression: error +--- +TOML parse error at line 2, column 15 + | +2 | line-length = 400 + | ^^^ +The line length must be a value between 1 and 320, not 400. diff --git a/crates/workspace/src/toml.rs b/crates/workspace/src/toml.rs new file mode 100644 index 00000000..98ffb238 --- /dev/null +++ b/crates/workspace/src/toml.rs @@ -0,0 +1,117 @@ +//! Utilities for locating (and extracting configuration from) an air.toml. + +use crate::toml_options::TomlOptions; +use std::fmt::Display; +use std::fmt::Formatter; +use std::io; +use std::path::{Path, PathBuf}; + +/// Parse an `air.toml` file. +pub fn parse_air_toml>(path: P) -> Result { + let contents = std::fs::read_to_string(path.as_ref()) + .map_err(|err| ParseTomlError::Read(path.as_ref().to_path_buf(), err))?; + + toml::from_str(&contents) + .map_err(|err| ParseTomlError::Deserialize(path.as_ref().to_path_buf(), err)) +} + +#[derive(Debug)] +pub enum ParseTomlError { + Read(PathBuf, io::Error), + Deserialize(PathBuf, toml::de::Error), +} + +impl std::error::Error for ParseTomlError {} + +impl Display for ParseTomlError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read(path, err) => { + write!( + f, + "Failed to read {path}:\n{err}", + path = fs::relativize_path(path), + ) + } + Self::Deserialize(path, err) => { + write!( + f, + "Failed to parse {path}:\n{err}", + path = fs::relativize_path(path), + ) + } + } + } +} + +/// Return the path to the `air.toml` file in a given directory. +pub fn find_air_toml_in_directory>(path: P) -> Option { + // Check for `air.toml`. + let toml = path.as_ref().join("air.toml"); + + if toml.is_file() { + Some(toml) + } else { + None + } +} + +/// Find the path to the closest `air.toml` if one exists, walking up the filesystem +pub fn find_air_toml>(path: P) -> Option { + for directory in path.as_ref().ancestors() { + if let Some(toml) = find_air_toml_in_directory(directory) { + return Some(toml); + } + } + None +} + +#[cfg(test)] +mod tests { + use anyhow::{Context, Result}; + use std::fs; + use tempfile::TempDir; + + use crate::settings::LineEnding; + use crate::toml::find_air_toml; + use crate::toml::parse_air_toml; + use crate::toml_options::TomlOptions; + + #[test] + + fn deserialize_empty() -> Result<()> { + let options: TomlOptions = toml::from_str(r"")?; + assert_eq!(options.global.indent_width, None); + assert_eq!(options.global.line_length, None); + assert_eq!(options.format, None); + Ok(()) + } + + #[test] + fn find_and_parse_air_toml() -> Result<()> { + let tempdir = TempDir::new()?; + let toml = tempdir.path().join("air.toml"); + fs::write( + toml, + r#" +line-length = 88 + +[format] +line-ending = "auto" +"#, + )?; + + let toml = find_air_toml(tempdir.path()).context("Failed to find air.toml")?; + let options = parse_air_toml(toml)?; + + let line_ending = options + .format + .context("Expected to find [format] table")? + .line_ending + .context("Expected to find `line-ending` field")?; + + assert_eq!(line_ending, LineEnding::Auto); + + Ok(()) + } +} diff --git a/crates/workspace/src/toml_options.rs b/crates/workspace/src/toml_options.rs new file mode 100644 index 00000000..fb397d6a --- /dev/null +++ b/crates/workspace/src/toml_options.rs @@ -0,0 +1,121 @@ +use crate::settings::FormatSettings; +use crate::settings::IndentStyle; +use crate::settings::IndentWidth; +use crate::settings::LineEnding; +use crate::settings::LineLength; +use crate::settings::MagicLineBreak; +use crate::settings::Settings; + +/// The Rust representation of `air.toml` +/// +/// The names and types of the fields in this struct determine the names and types +/// that can be specified in the `air.toml`. +/// +/// Every field is optional at this point, nothing is "finalized". +/// Finalization is done in [TomlOptions::into_settings]. +/// +/// Global options are specified at top level in the TOML file. +/// All other options are nested within their own `[table]`. +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct TomlOptions { + /// Global options affecting multiple commands. + #[serde(flatten)] + pub global: GlobalTomlOptions, + + /// Options to configure code formatting. + pub format: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct GlobalTomlOptions { + /// The line length at which the formatter prefers to wrap lines. + /// + /// The value must be greater than or equal to `1` and less than or equal to `320`. + /// + /// Note: While the formatter will attempt to format lines such that they remain + /// within the `line-length`, it isn't a hard upper bound, and formatted lines may + /// exceed the `line-length`. + pub line_length: Option, + + /// The number of spaces per indentation level (tab). + /// + /// The value must be greater than or equal to `1` and less than or equal to `24`. + /// + /// Used by the formatter to determine the visual width of a tab. + /// + /// This option changes the number of spaces the formatter inserts when + /// using `indent-style = "space"`. It also represents the width of a tab when + /// `indent-style = "tab"` for the purposes of computing the `line-length`. + pub indent_width: Option, +} + +/// Configures the way air formats your code. +#[derive(Clone, Debug, PartialEq, Eq, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct FormatTomlOptions { + /// Whether to use spaces or tabs for indentation. + /// + /// `indent-style = "tab"` (default): + /// + /// ```r + /// fn <- function() { + /// cat("Hello") # A tab `\t` indents the `cat()` call. + /// } + /// ``` + /// + /// `indent-style = "space"`: + /// + /// ```r + /// fn <- function() { + /// cat("Hello") # Spaces indent the `cat()` call. + /// } + /// ``` + /// + /// We recommend you use tabs for accessibility. + /// + /// See `indent-width` to configure the number of spaces per indentation and the tab width. + pub indent_style: Option, + + /// The character air uses at the end of a line. + /// + /// * `auto`: The newline style is detected automatically on a file per file basis. Files with mixed line endings will be converted to the first detected line ending. Defaults to `\n` for files that contain no line endings. + /// * `lf`: Line endings will be converted to `\n`. The default line ending on Unix. + /// * `cr-lf`: Line endings will be converted to `\r\n`. The default line ending on Windows. + /// * `native`: Line endings will be converted to `\n` on Unix and `\r\n` on Windows. + pub line_ending: Option, + + /// Air respects a small set of magic line breaks as an indication that certain + /// function calls or function signatures should be left expanded. If this option + /// is set to `true`, magic line breaks are ignored. + /// + /// It may be preferable to ignore magic line breaks if you prefer that `line-length` + /// should be the only value that influences line breaks. + pub ignore_magic_line_break: Option, +} + +impl TomlOptions { + pub fn into_settings(self) -> Settings { + let format = self.format.unwrap_or_default(); + + let format = FormatSettings { + indent_style: format.indent_style.unwrap_or_default(), + indent_width: self.global.indent_width.unwrap_or_default(), + line_ending: format.line_ending.unwrap_or_default(), + line_length: self.global.line_length.unwrap_or_default(), + magic_line_break: match format.ignore_magic_line_break { + Some(ignore_magic_line_break) => { + if ignore_magic_line_break { + MagicLineBreak::Ignore + } else { + MagicLineBreak::Respect + } + } + None => MagicLineBreak::Respect, + }, + }; + + Settings { format } + } +}