From 8045f6c85264aa7b2f6ae6e1489fbd6f27cecf8d Mon Sep 17 00:00:00 2001 From: Davis Vaughan Date: Fri, 20 Dec 2024 15:21:28 -0500 Subject: [PATCH] Add support for reading air.toml in the CLI and LSP --- Cargo.lock | 78 +++++- Cargo.toml | 7 +- crates/air/Cargo.toml | 3 +- crates/air/src/commands/format.rs | 88 ++---- crates/air_r_formatter/src/context.rs | 14 + crates/air_r_formatter/src/lib.rs | 1 + crates/air_r_formatter/src/options.rs | 3 + .../src/options/magic_line_break.rs | 44 +++ crates/lsp/Cargo.toml | 1 + crates/lsp/src/capabilities.rs | 40 +++ crates/lsp/src/error.rs | 52 ++++ crates/lsp/src/handlers.rs | 41 ++- crates/lsp/src/handlers_format.rs | 25 +- crates/lsp/src/handlers_state.rs | 98 ++++--- crates/lsp/src/lib.rs | 3 + crates/lsp/src/main_loop.rs | 53 ++-- ...__tests__format_range_logical_lines-4.snap | 2 +- ...tests__format_range_unmatched_lists-2.snap | 2 +- ...tests__format_range_unmatched_lists-3.snap | 2 +- ...tests__format_range_unmatched_lists-4.snap | 2 +- ...__tests__format_range_unmatched_lists.snap | 2 +- crates/lsp/src/state.rs | 8 - crates/lsp/src/tower_lsp.rs | 2 +- crates/lsp/src/workspaces.rs | 174 ++++++++++++ crates/workspace/Cargo.toml | 31 +++ crates/workspace/src/lib.rs | 4 + crates/workspace/src/resolve.rs | 252 ++++++++++++++++++ crates/workspace/src/settings.rs | 60 +++++ crates/workspace/src/settings/indent_style.rs | 54 ++++ crates/workspace/src/settings/indent_width.rs | 148 ++++++++++ crates/workspace/src/settings/line_ending.rs | 31 +++ crates/workspace/src/settings/line_length.rs | 145 ++++++++++ .../src/settings/magic_line_break.rs | 53 ++++ ...__tests__deserialize_oob_indent_width.snap | 9 + ...h__tests__deserialize_oob_line_length.snap | 9 + crates/workspace/src/toml.rs | 117 ++++++++ crates/workspace/src/toml_options.rs | 121 +++++++++ editors/code/src/lsp.ts | 5 - 38 files changed, 1616 insertions(+), 168 deletions(-) create mode 100644 crates/air_r_formatter/src/options.rs create mode 100644 crates/air_r_formatter/src/options/magic_line_break.rs create mode 100644 crates/lsp/src/capabilities.rs create mode 100644 crates/lsp/src/error.rs create mode 100644 crates/lsp/src/workspaces.rs create mode 100644 crates/workspace/Cargo.toml create mode 100644 crates/workspace/src/lib.rs create mode 100644 crates/workspace/src/resolve.rs create mode 100644 crates/workspace/src/settings.rs create mode 100644 crates/workspace/src/settings/indent_style.rs create mode 100644 crates/workspace/src/settings/indent_width.rs create mode 100644 crates/workspace/src/settings/line_ending.rs create mode 100644 crates/workspace/src/settings/line_length.rs create mode 100644 crates/workspace/src/settings/magic_line_break.rs create mode 100644 crates/workspace/src/settings/snapshots/workspace__settings__indent_width__tests__deserialize_oob_indent_width.snap create mode 100644 crates/workspace/src/settings/snapshots/workspace__settings__line_length__tests__deserialize_oob_line_length.snap create mode 100644 crates/workspace/src/toml.rs create mode 100644 crates/workspace/src/toml_options.rs diff --git a/Cargo.lock b/Cargo.lock index 2985a699..dca9edda 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", ] @@ -1346,6 +1349,7 @@ dependencies = [ "triomphe", "url", "uuid", + "workspace", ] [[package]] @@ -1672,9 +1676,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" @@ -1849,6 +1853,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" @@ -2203,6 +2216,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" @@ -2637,6 +2684,33 @@ 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", + "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..2627d3a6 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" @@ -59,14 +60,17 @@ line-index = "0.1.2" memchr = "2.7.4" path-absolutize = "3.1.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"] } @@ -124,7 +128,6 @@ unnecessary_join = "warn" unnested_or_patterns = "warn" unreadable_literal = "warn" verbose_bit_mask = "warn" -zero_sized_map_values = "warn" # restriction cfg_not_test = "warn" diff --git a/crates/air/Cargo.toml b/crates/air/Cargo.toml index e11590ad..305e707f 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..d2feacf8 100644 --- a/crates/air/src/commands/format.rs +++ b/crates/air/src/commands/format.rs @@ -8,23 +8,32 @@ 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::discover_r_file_paths; +use workspace::resolve::SettingsResolver; +use workspace::settings::FormatSettings; +use workspace::settings::Settings; 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 = discover_r_file_paths(&command.paths); + + let mut resolver = SettingsResolver::new(Settings::default()); + resolver.load_from_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_or_fallback(&path); + format_file(path, mode, &settings.format) + } Err(err) => Err(err.into()), }) .partition_map(|result| match result { @@ -99,62 +108,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 +119,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/lsp/Cargo.toml b/crates/lsp/Cargo.toml index 3c726341..6a356eb2 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -42,6 +42,7 @@ tree-sitter-r.workspace = true triomphe.workspace = true url.workspace = true uuid = { workspace = true, features = ["v4"] } +workspace = { workspace = true } [dev-dependencies] assert_matches.workspace = true diff --git a/crates/lsp/src/capabilities.rs b/crates/lsp/src/capabilities.rs new file mode 100644 index 00000000..9b46941f --- /dev/null +++ b/crates/lsp/src/capabilities.rs @@ -0,0 +1,40 @@ +use tower_lsp::lsp_types::ClientCapabilities; +use tower_lsp::lsp_types::PositionEncodingKind; + +/// A resolved representation of the [ClientCapabilities] the Client sends over that we +/// actually do something with +#[derive(Debug, Default)] +pub(crate) struct ResolvedClientCapabilities { + pub(crate) position_encodings: Vec, + pub(crate) dynamic_registration_for_did_change_configuration: bool, + pub(crate) dynamic_registration_for_did_change_watched_files: bool, +} + +impl ResolvedClientCapabilities { + pub(crate) fn new(capabilities: ClientCapabilities) -> Self { + let position_encodings = capabilities + .general + .and_then(|general_client_capabilities| general_client_capabilities.position_encodings) + .unwrap_or(vec![PositionEncodingKind::UTF16]); + + let dynamic_registration_for_did_change_configuration = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_configuration) + .and_then(|did_change_configuration| did_change_configuration.dynamic_registration) + .unwrap_or(false); + + let dynamic_registration_for_did_change_watched_files = capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_watched_files) + .and_then(|watched_files| watched_files.dynamic_registration) + .unwrap_or_default(); + + Self { + position_encodings, + dynamic_registration_for_did_change_configuration, + dynamic_registration_for_did_change_watched_files, + } + } +} diff --git a/crates/lsp/src/error.rs b/crates/lsp/src/error.rs new file mode 100644 index 00000000..fe19c650 --- /dev/null +++ b/crates/lsp/src/error.rs @@ -0,0 +1,52 @@ +/// A tool for collecting multiple anyhow errors into a single [`anyhow::Result`] +/// +/// Only applicable if the intended `Ok()` value at the end is `()`. +#[derive(Debug, Default)] +pub(crate) struct ErrorVec { + errors: Vec, +} + +impl ErrorVec { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Conditionally push to the error vector if the `result` is an `Err` case + pub(crate) fn push_err(&mut self, result: anyhow::Result) { + match result { + Ok(_) => (), + Err(error) => self.push(error), + } + } + + /// Push a new error to the error vector + pub(crate) fn push(&mut self, error: anyhow::Error) { + self.errors.push(error); + } + + /// Convert a error vector into a single [`anyhow::Result`] that knows how to print + /// each of the individual errors + pub(crate) fn into_result(self) -> anyhow::Result<()> { + if self.errors.is_empty() { + Ok(()) + } else { + Err(anyhow::anyhow!(self)) + } + } +} + +impl std::error::Error for ErrorVec {} + +impl std::fmt::Display for ErrorVec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.errors.len() > 1 { + f.write_str("Multiple errors:\n")?; + } + + for error in &self.errors { + std::fmt::Display::fmt(error, f)?; + } + + Ok(()) + } +} diff --git a/crates/lsp/src/handlers.rs b/crates/lsp/src/handlers.rs index 53cb5ebc..e7c54902 100644 --- a/crates/lsp/src/handlers.rs +++ b/crates/lsp/src/handlers.rs @@ -7,6 +7,8 @@ use struct_field_names_as_array::FieldNamesAsArray; use tower_lsp::lsp_types; +use tower_lsp::lsp_types::DidChangeWatchedFilesRegistrationOptions; +use tower_lsp::lsp_types::FileSystemWatcher; use tower_lsp::Client; use tracing::Instrument; @@ -24,30 +26,55 @@ pub(crate) async fn handle_initialized( let span = tracing::info_span!("handle_initialized").entered(); // Register capabilities to the client - let mut regs: Vec = vec![]; + let mut registrations: Vec = vec![]; - if lsp_state.needs_registration.did_change_configuration { + if lsp_state + .capabilities + .dynamic_registration_for_did_change_configuration + { // The `didChangeConfiguration` request instructs the client to send // a notification when the tracked settings have changed. // // Note that some settings, such as editor indentation properties, may be // changed by extensions or by the user without changing the actual // underlying setting. Unfortunately we don't receive updates in that case. - let mut config_document_regs = collect_regs( + let mut config_document_registrations = collect_regs( VscDocumentConfig::FIELD_NAMES_AS_ARRAY.to_vec(), VscDocumentConfig::section_from_key, ); - let mut config_diagnostics_regs: Vec = collect_regs( + let mut config_diagnostics_registrations: Vec = collect_regs( VscDiagnosticsConfig::FIELD_NAMES_AS_ARRAY.to_vec(), VscDiagnosticsConfig::section_from_key, ); - regs.append(&mut config_document_regs); - regs.append(&mut config_diagnostics_regs); + registrations.append(&mut config_document_registrations); + registrations.append(&mut config_diagnostics_registrations); + } + + if lsp_state + .capabilities + .dynamic_registration_for_did_change_watched_files + { + // Watch for changes in `air.toml` files so we can react dynamically + let watch_air_toml_registration = lsp_types::Registration { + id: uuid::Uuid::new_v4().to_string(), + method: "workspace/didChangeWatchedFiles".into(), + register_options: Some( + serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { + watchers: vec![FileSystemWatcher { + glob_pattern: lsp_types::GlobPattern::String("**/air.toml".into()), + kind: None, + }], + }) + .unwrap(), + ), + }; + + registrations.push(watch_air_toml_registration); } client - .register_capability(regs) + .register_capability(registrations) .instrument(span.exit()) .await?; Ok(()) diff --git a/crates/lsp/src/handlers_format.rs b/crates/lsp/src/handlers_format.rs index c9de94b3..3a635843 100644 --- a/crates/lsp/src/handlers_format.rs +++ b/crates/lsp/src/handlers_format.rs @@ -5,35 +5,32 @@ // // -use air_r_formatter::{context::RFormatOptions, format_node}; +use air_r_formatter::format_node; use air_r_syntax::{RExpressionList, RSyntaxKind, RSyntaxNode, WalkEvent}; -use biome_formatter::{IndentStyle, LineWidth}; use biome_rowan::{AstNode, Language, SyntaxElement}; use biome_text_size::{TextRange, TextSize}; use tower_lsp::lsp_types; +use crate::main_loop::LspState; use crate::state::WorldState; use crate::{from_proto, to_proto}; #[tracing::instrument(level = "info", skip_all)] pub(crate) fn document_formatting( params: lsp_types::DocumentFormattingParams, + lsp_state: &LspState, state: &WorldState, ) -> anyhow::Result>> { let doc = state.get_document(¶ms.text_document.uri)?; - let line_width = LineWidth::try_from(80).map_err(|err| anyhow::anyhow!("{err}"))?; - - // TODO: Handle FormattingOptions - let options = RFormatOptions::default() - .with_indent_style(IndentStyle::Space) - .with_line_width(line_width); + let settings = lsp_state.document_settings(¶ms.text_document.uri); + let format_options = settings.format.to_format_options(&doc.contents); if doc.parse.has_errors() { return Err(anyhow::anyhow!("Can't format when there are parse errors.")); } - let formatted = format_node(options.clone(), &doc.parse.syntax())?; + let formatted = format_node(format_options, &doc.parse.syntax())?; let output = formatted.print()?.into_code(); // Do we need to check that `doc` is indeed an R file? What about special @@ -47,18 +44,16 @@ pub(crate) fn document_formatting( #[tracing::instrument(level = "info", skip_all)] pub(crate) fn document_range_formatting( params: lsp_types::DocumentRangeFormattingParams, + lsp_state: &LspState, state: &WorldState, ) -> anyhow::Result>> { let doc = state.get_document(¶ms.text_document.uri)?; - let line_width = LineWidth::try_from(80).map_err(|err| anyhow::anyhow!("{err}"))?; let range = from_proto::text_range(&doc.line_index.index, params.range, doc.line_index.encoding)?; - // TODO: Handle FormattingOptions - let options = RFormatOptions::default() - .with_indent_style(IndentStyle::Space) - .with_line_width(line_width); + let settings = lsp_state.document_settings(¶ms.text_document.uri); + let format_options = settings.format.to_format_options(&doc.contents); let logical_lines = find_deepest_enclosing_logical_lines(doc.parse.syntax(), range); if logical_lines.is_empty() { @@ -96,7 +91,7 @@ pub(crate) fn document_range_formatting( let format_info = biome_formatter::format_sub_tree( root.syntax(), - air_r_formatter::RFormatLanguage::new(options), + air_r_formatter::RFormatLanguage::new(format_options), )?; if format_info.range().is_none() { diff --git a/crates/lsp/src/handlers_state.rs b/crates/lsp/src/handlers_state.rs index a570cf31..24b1bcdd 100644 --- a/crates/lsp/src/handlers_state.rs +++ b/crates/lsp/src/handlers_state.rs @@ -7,12 +7,15 @@ use anyhow::anyhow; use biome_lsp_converters::PositionEncoding; +use biome_lsp_converters::WideEncoding; use serde_json::Value; use struct_field_names_as_array::FieldNamesAsArray; use tower_lsp::lsp_types; use tower_lsp::lsp_types::ConfigurationItem; use tower_lsp::lsp_types::DidChangeConfigurationParams; use tower_lsp::lsp_types::DidChangeTextDocumentParams; +use tower_lsp::lsp_types::DidChangeWatchedFilesParams; +use tower_lsp::lsp_types::DidChangeWorkspaceFoldersParams; use tower_lsp::lsp_types::DidCloseTextDocumentParams; use tower_lsp::lsp_types::DidOpenTextDocumentParams; use tower_lsp::lsp_types::FormattingOptions; @@ -27,17 +30,21 @@ use tower_lsp::lsp_types::WorkspaceFoldersServerCapabilities; use tower_lsp::lsp_types::WorkspaceServerCapabilities; use tracing::Instrument; use url::Url; +use workspace::settings::Settings; +use crate::capabilities::ResolvedClientCapabilities; use crate::config::indent_style_from_lsp; use crate::config::DocumentConfig; use crate::config::VscDiagnosticsConfig; use crate::config::VscDocumentConfig; use crate::documents::Document; +use crate::error::ErrorVec; use crate::logging; use crate::logging::LogMessageSender; use crate::main_loop::LspState; use crate::state::workspace_uris; use crate::state::WorldState; +use crate::workspaces::WorkspaceSettingsResolver; // Handlers that mutate the world state @@ -62,7 +69,6 @@ pub struct ConsoleInputs { pub(crate) fn initialize( params: InitializeParams, lsp_state: &mut LspState, - state: &mut WorldState, log_tx: LogMessageSender, ) -> anyhow::Result { // TODO: Get user specified options from `params.initialization_options` @@ -76,44 +82,30 @@ pub(crate) fn initialize( params.client_info.as_ref(), ); - // Defaults to UTF-16 - let mut position_encoding = None; - - if let Some(caps) = params.capabilities.general { - // If the client supports UTF-8 we use that, even if it's not its - // preferred encoding (at position 0). Otherwise we use the mandatory - // UTF-16 encoding that all clients and servers must support, even if - // the client would have preferred UTF-32. Note that VSCode and Positron - // only support UTF-16. - if let Some(caps) = caps.position_encodings { - if caps.contains(&lsp_types::PositionEncodingKind::UTF8) { - lsp_state.position_encoding = PositionEncoding::Utf8; - position_encoding = Some(lsp_types::PositionEncodingKind::UTF8); - } - } - } - - // Take note of supported capabilities so we can register them in the - // `Initialized` handler - if let Some(ws_caps) = params.capabilities.workspace { - if matches!(ws_caps.did_change_configuration, Some(caps) if matches!(caps.dynamic_registration, Some(true))) - { - lsp_state.needs_registration.did_change_configuration = true; - } - } + // Initialize the workspace settings resolver using the initial set of client provided `workspace_folders` + lsp_state.workspace_settings_resolver = WorkspaceSettingsResolver::from_workspace_folders( + params.workspace_folders.unwrap_or_default(), + Settings::default(), + ); - // Initialize the workspace folders - let mut folders: Vec = Vec::new(); - if let Some(workspace_folders) = params.workspace_folders { - for folder in workspace_folders.iter() { - state.workspace.folders.push(folder.uri.clone()); - if let Ok(path) = folder.uri.to_file_path() { - if let Some(path) = path.to_str() { - folders.push(path.to_string()); - } - } - } - } + lsp_state.capabilities = ResolvedClientCapabilities::new(params.capabilities); + + // If the client supports UTF-8 we use that, even if it's not its + // preferred encoding (at position 0). Otherwise we use the mandatory + // UTF-16 encoding that all clients and servers must support, even if + // the client would have preferred UTF-32. Note that VSCode and Positron + // only support UTF-16. + let position_encoding = if lsp_state + .capabilities + .position_encodings + .contains(&lsp_types::PositionEncodingKind::UTF8) + { + lsp_state.position_encoding = PositionEncoding::Utf8; + Some(lsp_types::PositionEncodingKind::UTF8) + } else { + lsp_state.position_encoding = PositionEncoding::Wide(WideEncoding::Utf16); + Some(lsp_types::PositionEncodingKind::UTF16) + }; Ok(InitializeResult { server_info: Some(ServerInfo { @@ -199,6 +191,36 @@ pub(crate) async fn did_change_configuration( .await } +pub(crate) fn did_change_workspace_folders( + params: DidChangeWorkspaceFoldersParams, + lsp_state: &mut LspState, +) -> anyhow::Result<()> { + // Collect all `errors` to ensure we don't drop events after a first error + let mut errors = ErrorVec::new(); + + for lsp_types::WorkspaceFolder { uri, .. } in params.event.added { + errors.push_err(lsp_state.open_workspace_folder(&uri, Settings::default())); + } + for lsp_types::WorkspaceFolder { uri, .. } in params.event.removed { + errors.push_err(lsp_state.close_workspace_folder(&uri)); + } + + errors.into_result() +} + +pub(crate) fn did_change_watched_files( + params: DidChangeWatchedFilesParams, + lsp_state: &mut LspState, +) -> anyhow::Result<()> { + for change in ¶ms.changes { + lsp_state + .workspace_settings_resolver + .reload_workspaces_matched_by_url(&change.uri); + } + + Ok(()) +} + #[tracing::instrument(level = "info", skip_all)] pub(crate) fn did_change_formatting_options( uri: &Url, diff --git a/crates/lsp/src/lib.rs b/crates/lsp/src/lib.rs index 65451b9f..adb21047 100644 --- a/crates/lsp/src/lib.rs +++ b/crates/lsp/src/lib.rs @@ -3,10 +3,12 @@ pub use tower_lsp::start_lsp; +pub mod capabilities; pub mod config; pub mod crates; pub mod documents; pub mod encoding; +pub mod error; pub mod from_proto; pub mod handlers; pub mod handlers_ext; @@ -18,6 +20,7 @@ pub mod rust_analyzer; pub mod state; pub mod to_proto; pub mod tower_lsp; +pub mod workspaces; #[cfg(test)] pub mod test_utils; diff --git a/crates/lsp/src/main_loop.rs b/crates/lsp/src/main_loop.rs index 780a635b..dd0037df 100644 --- a/crates/lsp/src/main_loop.rs +++ b/crates/lsp/src/main_loop.rs @@ -18,7 +18,10 @@ use tokio::task::JoinHandle; use tower_lsp::lsp_types::Diagnostic; use tower_lsp::Client; use url::Url; +use workspace::resolve::SettingsResolver; +use workspace::settings::Settings; +use crate::capabilities::ResolvedClientCapabilities; use crate::handlers; use crate::handlers_ext; use crate::handlers_format; @@ -31,6 +34,7 @@ use crate::tower_lsp::LspMessage; use crate::tower_lsp::LspNotification; use crate::tower_lsp::LspRequest; use crate::tower_lsp::LspResponse; +use crate::workspaces::WorkspaceSettingsResolver; pub(crate) type TokioUnboundedSender = tokio::sync::mpsc::UnboundedSender; pub(crate) type TokioUnboundedReceiver = tokio::sync::mpsc::UnboundedReceiver; @@ -148,6 +152,9 @@ pub(crate) struct GlobalState { /// Unlike `WorldState`, `ParserState` cannot be cloned and is only accessed by /// exclusive handlers. pub(crate) struct LspState { + /// Resolver to look up [`Settings`] given a document [`Url`] + pub(crate) workspace_settings_resolver: WorkspaceSettingsResolver, + /// The negociated encoding for document positions. Note that documents are /// always stored as UTF-8 in Rust Strings. This encoding is only used to /// translate UTF-16 positions sent by the client to UTF-8 ones. @@ -156,26 +163,42 @@ pub(crate) struct LspState { /// The set of tree-sitter document parsers managed by the `GlobalState`. pub(crate) parsers: HashMap, - /// List of capabilities for which we need to send a registration request - /// when we get the `Initialized` notification. - pub(crate) needs_registration: ClientCaps, - // Add handle to aux loop here? + /// List of client capabilities that we care about + pub(crate) capabilities: ResolvedClientCapabilities, } impl Default for LspState { fn default() -> Self { Self { + workspace_settings_resolver: WorkspaceSettingsResolver::default(), // Default encoding specified in the LSP protocol position_encoding: PositionEncoding::Wide(WideEncoding::Utf16), parsers: Default::default(), - needs_registration: Default::default(), + capabilities: ResolvedClientCapabilities::default(), } } } -#[derive(Debug, Default)] -pub(crate) struct ClientCaps { - pub(crate) did_change_configuration: bool, +impl LspState { + pub(crate) fn document_settings(&self, url: &Url) -> &Settings { + self.workspace_settings_resolver.settings_for_url(url) + } + + pub(crate) fn open_workspace_folder( + &mut self, + url: &Url, + fallback: Settings, + ) -> anyhow::Result<()> { + self.workspace_settings_resolver + .open_workspace_folder(url, fallback) + } + + pub(crate) fn close_workspace_folder( + &mut self, + url: &Url, + ) -> anyhow::Result> { + self.workspace_settings_resolver.close_workspace_folder(url) + } } enum LoopControl { @@ -300,14 +323,14 @@ impl GlobalState { LspNotification::Initialized(_params) => { handlers::handle_initialized(&self.client, &self.lsp_state).await?; }, - LspNotification::DidChangeWorkspaceFolders(_params) => { - // TODO: Restart indexer with new folders. + LspNotification::DidChangeWorkspaceFolders(params) => { + handlers_state::did_change_workspace_folders(params, &mut self.lsp_state)?; }, LspNotification::DidChangeConfiguration(params) => { handlers_state::did_change_configuration(params, &self.client, &mut self.world).await?; }, - LspNotification::DidChangeWatchedFiles(_params) => { - // TODO: Re-index the changed files. + LspNotification::DidChangeWatchedFiles(params) => { + handlers_state::did_change_watched_files(params, &mut self.lsp_state)?; }, LspNotification::DidOpenTextDocument(params) => { handlers_state::did_open(params, &self.lsp_state, &mut self.world)?; @@ -329,17 +352,17 @@ impl GlobalState { LspRequest::Initialize(params) => { // Unwrap: `Initialize` method should only be called once. let log_tx = self.log_tx.take().unwrap(); - respond(tx, handlers_state::initialize(params, &mut self.lsp_state, &mut self.world, log_tx), LspResponse::Initialize)?; + respond(tx, handlers_state::initialize(params, &mut self.lsp_state, log_tx), LspResponse::Initialize)?; }, LspRequest::Shutdown => { out = LoopControl::Shutdown; respond(tx, Ok(()), LspResponse::Shutdown)?; }, LspRequest::DocumentFormatting(params) => { - respond(tx, handlers_format::document_formatting(params, &self.world), LspResponse::DocumentFormatting)?; + respond(tx, handlers_format::document_formatting(params, &self.lsp_state, &self.world), LspResponse::DocumentFormatting)?; }, LspRequest::DocumentRangeFormatting(params) => { - respond(tx, handlers_format::document_range_formatting(params, &self.world), LspResponse::DocumentRangeFormatting)?; + respond(tx, handlers_format::document_range_formatting(params, &self.lsp_state, &self.world), LspResponse::DocumentRangeFormatting)?; }, LspRequest::AirViewFile(params) => { respond(tx, handlers_ext::view_file(params, &self.world), LspResponse::AirViewFile)?; diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap index cda77a86..478729cf 100644 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap +++ b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_logical_lines-4.snap @@ -4,5 +4,5 @@ expression: output --- 1+1 { - 2 + 2 + 2 + 2 } diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap index 776dab17..8c4b6082 100644 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap +++ b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-2.snap @@ -5,6 +5,6 @@ expression: output2 0+0 1 + 1 { - 2 + 2 + 2 + 2 } 3+3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap index 5d9e27d0..eccb6f4e 100644 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap +++ b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-3.snap @@ -5,6 +5,6 @@ expression: output3 0+0 1 + 1 { - 2 + 2 + 2 + 2 } 3 + 3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap index 4234f400..699c29fb 100644 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap +++ b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists-4.snap @@ -5,6 +5,6 @@ expression: output4 0+0 1+1 { - 2 + 2 + 2 + 2 } 3 + 3 diff --git a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap index 33752478..6587090f 100644 --- a/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap +++ b/crates/lsp/src/snapshots/lsp__handlers_format__tests__format_range_unmatched_lists.snap @@ -5,6 +5,6 @@ expression: output1 0+0 1 + 1 { - 2 + 2 + 2 + 2 } 3+3 diff --git a/crates/lsp/src/state.rs b/crates/lsp/src/state.rs index 1bd38433..219a5165 100644 --- a/crates/lsp/src/state.rs +++ b/crates/lsp/src/state.rs @@ -14,9 +14,6 @@ pub(crate) struct WorldState { /// Watched documents pub(crate) documents: HashMap, - /// Watched folders - pub(crate) workspace: Workspace, - /// The scopes for the console. This currently contains a list (outer `Vec`) /// of names (inner `Vec`) within the environments on the search path, starting /// from the global environment and ending with the base package. Eventually @@ -46,11 +43,6 @@ pub(crate) struct WorldState { pub(crate) config: LspConfig, } -#[derive(Clone, Default, Debug)] -pub(crate) struct Workspace { - pub folders: Vec, -} - impl WorldState { pub(crate) fn get_document(&self, uri: &Url) -> anyhow::Result<&Document> { if let Some(doc) = self.documents.get(uri) { diff --git a/crates/lsp/src/tower_lsp.rs b/crates/lsp/src/tower_lsp.rs index ba114988..60de59d0 100644 --- a/crates/lsp/src/tower_lsp.rs +++ b/crates/lsp/src/tower_lsp.rs @@ -345,7 +345,7 @@ mod tests { text_document_sync, .. } => { - assert_eq!(position_encoding, None); + assert_eq!(position_encoding, Some(PositionEncodingKind::UTF16)); assert_eq!(text_document_sync, Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::INCREMENTAL))); }); diff --git a/crates/lsp/src/workspaces.rs b/crates/lsp/src/workspaces.rs new file mode 100644 index 00000000..1d64eae2 --- /dev/null +++ b/crates/lsp/src/workspaces.rs @@ -0,0 +1,174 @@ +use std::path::Path; +use std::path::PathBuf; + +use tower_lsp::lsp_types::WorkspaceFolder; +use url::Url; +use workspace::resolve::PathResolver; +use workspace::resolve::SettingsResolver; +use workspace::settings::Settings; + +/// Resolver for retrieving [`Settings`] associated with a workspace specific [`Path`] +#[derive(Debug, Default)] +pub(crate) struct WorkspaceSettingsResolver { + /// Resolves a `path` to the closest workspace specific `SettingsResolver`. + /// That `SettingsResolver` can then return `Settings` for the `path`. + path_to_settings_resolver: PathResolver, +} + +impl WorkspaceSettingsResolver { + /// Construct a new workspace settings resolver from an initial set of workspace folders + pub(crate) fn from_workspace_folders( + workspace_folders: Vec, + fallback: Settings, + ) -> Self { + let settings_resolver_fallback = SettingsResolver::new(fallback.clone()); + let path_to_settings_resolver = PathResolver::new(settings_resolver_fallback); + + let mut resolver = Self { + path_to_settings_resolver, + }; + + // Add each workspace folder's settings into the resolver. + // If we fail for any reason (i.e. parse failure of an `air.toml`) then + // we log an error and try to resolve the remaining workspace folders. We don't want + // to propagate an error here because we don't want to prevent the server from + // starting up entirely. + // TODO: This is one place it would be nice to show a toast notification back + // to the user, but we probably need to add support to the Aux thread for that? + for workspace_folder in workspace_folders { + if let Err(error) = + resolver.open_workspace_folder(&workspace_folder.uri, fallback.clone()) + { + tracing::error!( + "Failed to load workspace settings for '{uri}':\n{error}", + uri = workspace_folder.uri, + error = error + ); + } + } + + resolver + } + + pub(crate) fn open_workspace_folder( + &mut self, + url: &Url, + fallback: Settings, + ) -> anyhow::Result<()> { + let path = match Self::url_to_path(url)? { + Some(path) => path, + None => { + tracing::warn!("Ignoring non-file workspace URL: {url}"); + return Ok(()); + } + }; + + let mut settings_resolver = SettingsResolver::new(fallback); + settings_resolver.load_from_paths(&[&path])?; + + tracing::trace!("Adding workspace settings: {}", path.display()); + self.path_to_settings_resolver.add(&path, settings_resolver); + + Ok(()) + } + + pub(crate) fn close_workspace_folder( + &mut self, + url: &Url, + ) -> anyhow::Result> { + match Self::url_to_path(url)? { + Some(path) => { + tracing::trace!("Removing workspace settings: {}", path.display()); + Ok(self.path_to_settings_resolver.remove(&path)) + } + None => { + tracing::warn!("Ignoring non-file workspace URL: {url}"); + Ok(None) + } + } + } + + /// Return the appropriate [`Settings`] for a given document [`Url`]. + pub(crate) fn settings_for_url(&self, url: &Url) -> &Settings { + if let Ok(Some(path)) = Self::url_to_path(url) { + return self.settings_for_path(&path); + } + + // For `untitled` schemes, we have special behavior. + // If there is exactly 1 workspace, we resolve using a path of + // `{workspace_path}/untitled` to provide relevant settings for this workspace. + if url.scheme() == "untitled" && self.path_to_settings_resolver.len() == 1 { + tracing::trace!("Using workspace settings for 'untitled' URL: {url}"); + let workspace_path = self.path_to_settings_resolver.keys().next().unwrap(); + let path = workspace_path.join("untitled"); + return self.settings_for_path(&path); + } + + tracing::trace!("Using default settings for non-file URL: {url}"); + self.path_to_settings_resolver.fallback().fallback() + } + + /// Reloads all workspaces matched by the [`Url`] + /// + /// This is utilized by the watched files handler to reload the settings + /// resolver whenever an `air.toml` is modified. + pub(crate) fn reload_workspaces_matched_by_url(&mut self, url: &Url) { + let path = match Self::url_to_path(url) { + Ok(Some(path)) => path, + Ok(None) => { + tracing::trace!("Ignoring non-`file` changed URL: {url}"); + return; + } + Err(error) => { + tracing::error!("Failed to reload workspaces associated with {url}:\n{error}"); + return; + } + }; + + if !path.ends_with("air.toml") { + // We could get called with a changed file that isn't an `air.toml` if we are + // watching more than `air.toml` files + tracing::trace!("Ignoring non-`air.toml` changed URL: {url}"); + return; + } + + for (workspace_path, settings_resolver) in self.path_to_settings_resolver.matches_mut(&path) + { + tracing::trace!("Reloading workspace settings: {}", workspace_path.display()); + + settings_resolver.clear(); + + if let Err(error) = settings_resolver.load_from_paths(&[workspace_path]) { + tracing::error!( + "Failed to reload workspace settings for {path}:\n{error}", + path = workspace_path.display(), + error = error + ); + } + } + } + + /// Return the appropriate [`Settings`] for a given [`Path`]. + /// + /// This actually performs a double resolution. It first resolves to the + /// workspace specific `SettingsResolver` that matches this path, and then uses that + /// resolver to actually resolve the `Settings` for this path. We do it this way + /// to ensure we can easily add and remove workspaces (including all of their + /// hierarchical paths). + fn settings_for_path(&self, path: &Path) -> &Settings { + let settings_resolver = self.path_to_settings_resolver.resolve_or_fallback(path); + settings_resolver.resolve_or_fallback(path) + } + + fn url_to_path(url: &Url) -> anyhow::Result> { + if url.scheme() != "file" { + return Ok(None); + } + + let path = url + .to_file_path() + .map_err(|()| anyhow::anyhow!("Failed to convert workspace URL to file path: {url}"))?; + + Ok(Some(path)) + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml new file mode 100644 index 00000000..888fe4d6 --- /dev/null +++ b/crates/workspace/Cargo.toml @@ -0,0 +1,31 @@ +[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 } +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..c066e4e7 --- /dev/null +++ b/crates/workspace/src/resolve.rs @@ -0,0 +1,252 @@ +// --- source +// authors = ["Charlie Marsh"] +// license = "MIT" +// origin = "https://github.com/astral-sh/ruff/tree/main/crates/ruff_workspace" +// --- + +use std::collections::btree_map::Keys; +use std::collections::btree_map::Range; +use std::collections::btree_map::RangeMut; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use ignore::DirEntry; +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 [`Path`] to its associated `T` +/// +/// To use a [`PathResolver`]: +/// - Load directories into it using [`PathResolver::add()`] +/// - Resolve a [`Path`] to its associated `T` with [`PathResolver::resolve()`] +/// +/// See [`PathResolver::resolve()`] for more details on the implementation. +#[derive(Debug, Default)] +pub struct PathResolver { + /// Fallback value to be used when a `path` isn't associated with anything in the `map` + fallback: T, + + /// An ordered `BTreeMap` from a `path` (normally, a directory) to a `T` + map: BTreeMap, +} + +impl PathResolver { + /// Create a new empty [`PathResolver`] + pub fn new(fallback: T) -> Self { + Self { + fallback, + map: BTreeMap::new(), + } + } + + pub fn fallback(&self) -> &T { + &self.fallback + } + + pub fn add(&mut self, path: &Path, value: T) -> Option { + self.map.insert(path.to_path_buf(), value) + } + + pub fn remove(&mut self, path: &Path) -> Option { + self.map.remove(path) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + pub fn keys(&self) -> Keys<'_, PathBuf, T> { + self.map.keys() + } + + pub fn clear(&mut self) { + self.map.clear(); + } + + /// Resolve a [`Path`] to its associated `T` + /// + /// This resolver works by finding the closest directory to the `path` to search for. + /// + /// The [`BTreeMap`] is an ordered map, so if you do: + /// + /// ```text + /// resolver.add("a/b", value1) + /// resolver.add("a/b/c", value2) + /// resolver.add("a/b/d", value3) + /// resolver.resolve("a/b/c/test.R") + /// ``` + /// + /// Then it detects both `"a/b"` and `"a/b/c"` as being "less than" the path of + /// `"a/b/c/test.R"`, and then chooses `"a/b/c"` because it is at the back of + /// that returned sorted list (i.e. the "closest" match). + pub fn resolve(&self, path: &Path) -> Option<&T> { + self.resolve_entry(path).map(|(_, value)| value) + } + + /// Same as `resolve()`, but returns the internal `fallback` if no associated value + /// is found. + pub fn resolve_or_fallback(&self, path: &Path) -> &T { + self.resolve(path).unwrap_or(self.fallback()) + } + + /// Same as `resolve()`, but returns the `(key, value)` pair. + /// + /// Useful when you need the matched workspace path + pub fn resolve_entry(&self, path: &Path) -> Option<(&PathBuf, &T)> { + self.matches(path).next_back() + } + + /// Returns all matches matched by the `path` rather than just the closest one + pub fn matches(&self, path: &Path) -> Range<'_, PathBuf, T> { + self.map.range(..path.to_path_buf()) + } + + /// Returns all matches matched by the `path` rather than just the closest one + pub fn matches_mut(&mut self, path: &Path) -> RangeMut<'_, PathBuf, T> { + self.map.range_mut(..path.to_path_buf()) + } +} + +pub type SettingsResolver = PathResolver; + +#[derive(Debug, Error)] +pub enum SettingsResolverError { + #[error(transparent)] + ParseToml(#[from] ParseTomlError), +} + +impl SettingsResolver { + /// This is the core function for walking a set of `paths` looking for `air.toml`s + /// and loading in any directories it finds + /// + /// For each `path`, we: + /// - Walk up its ancestors, looking for an `air.toml` + /// - TODO(hierarchical): Walk down its children, looking for nested `air.toml`s + /// + /// Whenever we find an `air.toml`, we add the directory it was found in and + /// the parsed [`Settings`] into the resolver. + pub fn load_from_paths>( + &mut self, + paths: &[P], + ) -> Result<(), SettingsResolverError> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + 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 = Self::parse_settings(&toml)?; + self.add(ancestor, settings); + break; + } + } else { + // We already visited this ancestor, we can stop here. + break; + } + } + } + + // TODO(hierarchical): Also iterate through the directories and collect `air.toml` + // found nested withing the directories for hierarchical support + + Ok(()) + } + + /// Parse [Settings] from a given `air.toml` + // TODO(hierarchical): 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) + } +} + +/// For each provided `path`, recursively search for any R files within that `path` +/// that match our inclusion criteria +/// +/// NOTE: Make sure that the inclusion criteria that guide `path` discovery are also +/// consistently applied to `settings_resolver_from_paths()`. +pub fn discover_r_file_paths>(paths: &[P]) -> Vec> { + let paths: Vec = paths.iter().map(fs::normalize_path).collect(); + + let Some((first_path, paths)) = paths.split_first() else { + // No paths provided + return Vec::new(); + }; + + // 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(false); + builder.git_ignore(true); + builder.git_global(true); + builder.git_exclude(true); + + let mut paths = 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) { + paths.push(Ok(path)); + } + } + Err(err) => { + paths.push(Err(err)); + } + } + } + + paths +} + +// 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 } + } +} diff --git a/editors/code/src/lsp.ts b/editors/code/src/lsp.ts index f37fc43c..3c93faef 100644 --- a/editors/code/src/lsp.ts +++ b/editors/code/src/lsp.ts @@ -65,11 +65,6 @@ export class Lsp { { language: "r", pattern: "**/*.{r,R}" }, { language: "r", pattern: "**/*.{rprofile,Rprofile}" }, ], - synchronize: { - // Notify the server about file changes to R files contained in the workspace - fileEvents: - vscode.workspace.createFileSystemWatcher("**/*.[Rr]"), - }, outputChannel: this.channel, };