Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for reading air.toml in the CLI and LSP #122

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

DavisVaughan
Copy link
Collaborator

@DavisVaughan DavisVaughan commented Dec 20, 2024

Closes #83

The point of this PR is to allow air to read air.toml configuration files in your project and pass information from them on to format document / selection requests. There are 3 main parts:

  • Finding and reading air.toml files. This is all contained in a new workspace crate. We read air.toml files with the toml crate. The "spec" is the TomlOptions struct itself.

  • Utilizing the workspace tools in the CLI. This is pretty easy and small since each CLI call is a "one shot" discovery of air.toml configuration files (i.e. there is no state to maintain between CLI calls).

  • Utilizing the workspace tools in the LSP. This is much more involved because we want to:

    • Parse and cache the air.tomls we find and associate them correctly with R files in a workspace
    • Refresh the air.tomls when the user adds a new workspace, or if the user changes the air.toml

Comment on lines +7 to +11
pub(crate) struct ResolvedClientCapabilities {
pub(crate) position_encodings: Vec<PositionEncodingKind>,
pub(crate) dynamic_registration_for_did_change_configuration: bool,
pub(crate) dynamic_registration_for_did_change_watched_files: bool,
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collecting client capabilities we care about here. We get them in Initialize but use them in other places

Comment on lines +54 to +73
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);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Watching for changes in air.toml files anywhere within a workspace (creation, deletion, modify)

Comment on lines 19 to +27
pub(crate) fn document_formatting(
params: lsp_types::DocumentFormattingParams,
lsp_state: &LspState,
state: &WorldState,
) -> anyhow::Result<Option<Vec<lsp_types::TextEdit>>> {
let doc = state.get_document(&params.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(&params.text_document.uri);
let format_options = settings.format.to_format_options(&doc.contents);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After all was said and done this is the main change to the FormatDocument request code, pretty nice right now

Note that we are currently ignoring anything related to the document settings provided by the editor

Comment on lines +194 to +209
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()
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating our per-workspace settings resolver when a workspace is opened / closed

Comment on lines +211 to +222
pub(crate) fn did_change_watched_files(
params: DidChangeWatchedFilesParams,
lsp_state: &mut LspState,
) -> anyhow::Result<()> {
for change in &params.changes {
lsp_state
.workspace_settings_resolver
.reload_workspaces_matched_by_url(&change.uri);
}

Ok(())
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating our per-workspace settings resolver when an air.toml is changed

2 + 2
2 + 2
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LSP was hardcoded to Space before but we are going to get the default settings now, which are currently Tab

Comment on lines +91 to +109
/// 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()
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky little behavior here with untitled files

If there aren't any workspaces open, then the untitled file can't be associated with any air.toml

If there are >1 workspaces open, we can't make a reasonable guess about which workspace's air.toml to try and associate the untitled file with

If there is exactly 1 workspace open (the 95% case), then we can make a reasonable guess that we should use that workspace's air.toml with untitled files

Comment on lines +135 to +148
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
);
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this "refresh" behavior kind of awesome. We mutably iterate over the workspace specific resolvers that are associated with the updated air.toml and update them in place by clearing them and then rediscovering the air.tomls within them

Comment on lines +75 to +93
/// 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)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main thing to read about how path/to/file.R -> Settings resolution works

Comment on lines +198 to +209
// 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);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice that we will get gitignore support out of the box so we don't format any directories marked in a .gitignore

Comment on lines +37 to +59
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())
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finalized FormatSettings are really "mostly" finalized. Things like LineEnding::Auto get truly finalized when converting to RFormatOptions, because that's when we are most likely to have the source code to infer line endings for lying around.


#[derive(Copy, Clone, Debug, Eq, PartialEq, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LineEnding {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is very reasonable / inevitable that we end up with our own copies of these settings in addition to the ones from biome.

  • For each one we need a Deserialize method that works nicely with the toml crate and uses kebab-case
  • For some, like here, we add additional user facing options like Auto that don't make it into the underlying formatter option
  • For some, like IndentWidth, we may choose to have our own IndentWidth::default() that is different from what biome's is

Comment on lines +33 to +40
/// 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<LineLength>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff has some very cool macros that use TomlOptions as its "source of truth" for documentation.

It extracts out each table name (like format) and each option under the table (like line_length) along with all documentation comments and makes a really nice markdown page for it that ends up here:

https://docs.astral.sh/ruff/settings/

Without a page like this its hard to know what the spec is for what you can supply in air.toml

/// 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<IndentWidth>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both line_length and indent_width are top-level global options used by "all of air" because if we consider a linter then it will need access to these options to determine if a line exceeds the line length.

Compare that with indent_style, line_ending, and ignore_magic_line_break which are more clearly format specific and live under the [format] table header.

Comment on lines -68 to -72
synchronize: {
// Notify the server about file changes to R files contained in the workspace
fileEvents:
vscode.workspace.createFileSystemWatcher("**/*.[Rr]"),
},
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This causes the middleware to automatically start sending DidChangeWatchedFiles notifications to the Client

It was super confusing for me when I was registering the air.toml watched files notification because it looked like I had somehow done it wrong and was getting .R files too!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add a basic configuration parser for air.toml
1 participant