Expand ruff.configuration to allow inline config (#16296)

## Summary

[Internal design
document](https://www.notion.so/astral-sh/In-editor-settings-19e48797e1ca807fa8c2c91b689d9070?pvs=4)

This PR expands `ruff.configuration` to allow inline configuration
directly in the editor. For example:

```json
{
	"ruff.configuration": {
		"line-length": 100,
		"lint": {
			"unfixable": ["F401"],
			"flake8-tidy-imports": {
				"banned-api": {
					"typing.TypedDict": {
						"msg": "Use `typing_extensions.TypedDict` instead"
					}
				}
			}
		},
		"format": {
			"quote-style": "single"
		}
	}
}
```

This means that now `ruff.configuration` accepts either a path to
configuration file or the raw config itself. It's _mostly_ similar to
`--config` with one difference that's highlighted in the following
section. So, it can be said that the format of `ruff.configuration` when
provided the config map is same as the one on the [playground] [^1].

## Limitations

<details><summary><b>Casing (<code>kebab-case</code> v/s/
<code>camelCase</code>)</b></summary>
<p>


The config keys needs to be in `kebab-case` instead of `camelCase` which
is being used for other settings in the editor.

This could be a bit confusing. For example, the `line-length` option can
be set directly via an editor setting or can be configured via
`ruff.configuration`:

```json
{
	"ruff.configuration": {
        "line-length": 100
    },
    "ruff.lineLength": 120
}
```

#### Possible solution

We could use feature flag with [conditional
compilation](https://doc.rust-lang.org/reference/conditional-compilation.html#the-cfg_attr-attribute)
to indicate that when used in `ruff_server`, we need the `Options`
fields to be renamed as `camelCase` while for other crates it needs to
be renamed as `kebab-case`. But, this might not work very easily because
it will require wrapping the `Options` struct and create two structs in
which we'll have to add `#[cfg_attr(...)]` because otherwise `serde`
will complain:

```
error: duplicate serde attribute `rename_all`
  --> crates/ruff_workspace/src/options.rs:43:38
   |
43 | #[cfg_attr(feature = "editor", serde(rename_all = "camelCase"))]
   |                                      ^^^^^^^^^^
```

</p>
</details> 

<details><summary><b>Nesting (flat v/s nested keys)</b></summary>
<p>

This is the major difference between `--config` flag on the command-line
v/s `ruff.configuration` and it makes it such that `ruff.configuration`
has same value format as [playground] [^1].

The config keys needs to be split up into keys which can result in
nested structure instead of flat structure:

So, the following **won't work**:

```json
{
	"ruff.configuration": {
		"format.quote-style": "single",
		"lint.flake8-tidy-imports.banned-api.\"typing.TypedDict\".msg": "Use `typing_extensions.TypedDict` instead"
	}
}
```

But, instead it would need to be split up like the following:
```json
{
	"ruff.configuration": {
		"format": {
			"quote-style": "single"
		},
		"lint": {
			"flake8-tidy-imports": {
				"banned-api": {
					"typing.TypedDict": {
						"msg": "Use `typing_extensions.TypedDict` instead"
					}
				}
			}
		}
	}
}
```

#### Possible solution (1)

The way we could solve this and make it same as `--config` would be to
add a manual logic of converting the JSON map into an equivalent TOML
string which would be then parsed into `Options`.

So, the following JSON map:
```json
{ "lint.flake8-tidy-imports": { "banned-api": {"\"typing.TypedDict\".msg": "Use typing_extensions.TypedDict instead"}}}
```

would need to be converted into the following TOML string:
```toml
lint.flake8-tidy-imports = { banned-api = { "typing.TypedDict".msg = "Use typing_extensions.TypedDict instead" } }
```

by recursively convering `"key": value` into `key = value` which is to
remove the quotes from key and replacing `:` with `=`.

#### Possible solution (2)

Another would be to just accept `Map<String, String>` strictly and
convert it into `key = value` and then parse it as a TOML string. This
would also match `--config` but quotes might become a nuisance because
JSON only allows double quotes and so it'll require escaping any inner
quotes or use single quotes.

</p>
</details> 

## Test Plan

### VS Code

**Requires https://github.com/astral-sh/ruff-vscode/pull/702**

**`settings.json`**:
```json
{
  "ruff.lint.extendSelect": ["TID"],
  "ruff.configuration": {
    "line-length": 50,
    "format": {
      "quote-style": "single"
    },
    "lint": {
      "unfixable": ["F401"],
      "flake8-tidy-imports": {
        "banned-api": {
          "typing.TypedDict": {
            "msg": "Use `typing_extensions.TypedDict` instead"
          }
        }
      }
    }
  }
}
```

Following video showcases me doing the following:
1. Check diagnostics that it includes `TID`
2. Run `Ruff: Fix all auto-fixable problems` to test `unfixable`
3. Run `Format: Document` to test `line-length` and `quote-style`


https://github.com/user-attachments/assets/0a38176f-3fb0-4960-a213-73b2ea5b1180

### Neovim

**`init.lua`**:
```lua
require('lspconfig').ruff.setup {
  init_options = {
    settings = {
      lint = {
        extendSelect = { 'TID' },
      },
      configuration = {
        ['line-length'] = 50,
        format = {
          ['quote-style'] = 'single',
        },
        lint = {
          unfixable = { 'F401' },
          ['flake8-tidy-imports'] = {
            ['banned-api'] = {
              ['typing.TypedDict'] = {
                msg = 'Use typing_extensions.TypedDict instead',
              },
            },
          },
        },
      },
    },
  },
}
```

Same steps as in the VS Code test:



https://github.com/user-attachments/assets/cfe49a9b-9a89-43d7-94f2-7f565d6e3c9d

## Documentation Preview



https://github.com/user-attachments/assets/e0062f58-6ec8-4e01-889d-fac76fd8b3c7



[playground]: https://play.ruff.rs

[^1]: This has one advantage that the value can be copy-pasted directly
into the playground
This commit is contained in:
Dhruv Manilawala 2025-02-26 10:17:11 +05:30 committed by GitHub
parent 78806361fd
commit be03cb04c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 336 additions and 33 deletions

View file

@ -18,7 +18,9 @@ use ruff_workspace::{
resolver::{ConfigurationTransformer, Relativity},
};
use crate::session::settings::{ConfigurationPreference, ResolvedEditorSettings};
use crate::session::settings::{
ConfigurationPreference, ResolvedConfiguration, ResolvedEditorSettings,
};
#[derive(Debug)]
pub struct RuffSettings {
@ -363,21 +365,39 @@ impl ConfigurationTransformer for EditorConfigurationTransformer<'_> {
..Configuration::default()
};
// Merge in the editor-specified configuration file, if it exists.
let editor_configuration = if let Some(config_file_path) = configuration {
tracing::debug!(
"Combining settings from editor-specified configuration file at: {}",
config_file_path.display()
);
match open_configuration_file(&config_file_path) {
Ok(config_from_file) => editor_configuration.combine(config_from_file),
err => {
tracing::error!(
"{:?}",
err.context("Unable to load editor-specified configuration file")
.unwrap_err()
// Merge in the editor-specified configuration.
let editor_configuration = if let Some(configuration) = configuration {
match configuration {
ResolvedConfiguration::FilePath(path) => {
tracing::debug!(
"Combining settings from editor-specified configuration file at: {}",
path.display()
);
editor_configuration
match open_configuration_file(&path) {
Ok(config_from_file) => editor_configuration.combine(config_from_file),
err => {
tracing::error!(
"{:?}",
err.context("Unable to load editor-specified configuration file")
.unwrap_err()
);
editor_configuration
}
}
}
ResolvedConfiguration::Inline(options) => {
tracing::debug!(
"Combining settings from editor-specified inline configuration"
);
match Configuration::from_options(options, None, project_root) {
Ok(configuration) => editor_configuration.combine(configuration),
Err(err) => {
tracing::error!(
"Unable to load editor-specified inline configuration: {err:?}",
);
editor_configuration
}
}
}
}
} else {
@ -411,3 +431,47 @@ impl ConfigurationTransformer for IdentityTransformer {
config
}
}
#[cfg(test)]
mod tests {
use ruff_linter::line_width::LineLength;
use ruff_workspace::options::Options;
use super::*;
/// This test ensures that the inline configuration is correctly applied to the configuration.
#[test]
fn inline_settings() {
let editor_settings = ResolvedEditorSettings {
configuration: Some(ResolvedConfiguration::Inline(Options {
line_length: Some(LineLength::try_from(120).unwrap()),
..Default::default()
})),
..Default::default()
};
let config = EditorConfigurationTransformer(&editor_settings, Path::new("/src/project"))
.transform(Configuration::default());
assert_eq!(config.line_length.unwrap().value(), 120);
}
/// This test ensures that between the inline configuration and specific settings, the specific
/// settings is prioritized.
#[test]
fn inline_and_specific_settings_resolution_order() {
let editor_settings = ResolvedEditorSettings {
configuration: Some(ResolvedConfiguration::Inline(Options {
line_length: Some(LineLength::try_from(120).unwrap()),
..Default::default()
})),
line_length: Some(LineLength::try_from(100).unwrap()),
..Default::default()
};
let config = EditorConfigurationTransformer(&editor_settings, Path::new("/src/project"))
.transform(Configuration::default());
assert_eq!(config.line_length.unwrap().value(), 100);
}
}

View file

@ -3,8 +3,11 @@ use std::{ops::Deref, path::PathBuf, str::FromStr};
use lsp_types::Url;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde_json::{Map, Value};
use thiserror::Error;
use ruff_linter::{line_width::LineLength, RuleSelector};
use ruff_workspace::options::Options;
/// Maps a workspace URI to its associated client settings. Used during server initialization.
pub(crate) type WorkspaceSettingsMap = FxHashMap<Url, ClientSettings>;
@ -29,9 +32,9 @@ pub(crate) struct ResolvedClientSettings {
/// LSP client settings. These fields are optional because we don't want to override file-based linter/formatting settings
/// if these were un-set.
#[derive(Clone, Debug)]
#[cfg_attr(test, derive(PartialEq, Eq))]
#[cfg_attr(test, derive(Default, PartialEq, Eq))]
pub(crate) struct ResolvedEditorSettings {
pub(super) configuration: Option<PathBuf>,
pub(super) configuration: Option<ResolvedConfiguration>,
pub(super) lint_preview: Option<bool>,
pub(super) format_preview: Option<bool>,
pub(super) select: Option<Vec<RuleSelector>>,
@ -42,6 +45,48 @@ pub(crate) struct ResolvedEditorSettings {
pub(super) configuration_preference: ConfigurationPreference,
}
/// The resolved configuration from the client settings.
#[derive(Clone, Debug)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub(crate) enum ResolvedConfiguration {
FilePath(PathBuf),
Inline(Options),
}
impl TryFrom<&ClientConfiguration> for ResolvedConfiguration {
type Error = ResolvedConfigurationError;
fn try_from(value: &ClientConfiguration) -> Result<Self, Self::Error> {
match value {
ClientConfiguration::String(path) => Ok(ResolvedConfiguration::FilePath(
PathBuf::from(shellexpand::full(path)?.as_ref()),
)),
ClientConfiguration::Object(map) => {
let options = toml::Table::try_from(map)?.try_into::<Options>()?;
if options.extend.is_some() {
Err(ResolvedConfigurationError::ExtendNotSupported)
} else {
Ok(ResolvedConfiguration::Inline(options))
}
}
}
}
}
/// An error that can occur when trying to resolve the `configuration` value from the client
/// settings.
#[derive(Debug, Error)]
pub(crate) enum ResolvedConfigurationError {
#[error(transparent)]
EnvVarLookupError(#[from] shellexpand::LookupError<std::env::VarError>),
#[error("error serializing configuration to TOML: {0}")]
InvalidToml(#[from] toml::ser::Error),
#[error(transparent)]
InvalidRuffSchema(#[from] toml::de::Error),
#[error("using `extend` is unsupported for inline configuration")]
ExtendNotSupported,
}
/// Determines how multiple conflicting configurations should be resolved - in this
/// case, the configuration from the client settings and configuration from local
/// `.toml` files (aka 'workspace' configuration).
@ -57,12 +102,23 @@ pub(crate) enum ConfigurationPreference {
EditorOnly,
}
/// A direct representation of of `configuration` schema within the client settings.
#[derive(Debug, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))]
#[serde(untagged)]
enum ClientConfiguration {
/// A path to a configuration file.
String(String),
/// An object containing the configuration options.
Object(Map<String, Value>),
}
/// This is a direct representation of the settings schema sent by the client.
#[derive(Debug, Deserialize, Default)]
#[cfg_attr(test, derive(PartialEq, Eq))]
#[serde(rename_all = "camelCase")]
pub struct ClientSettings {
configuration: Option<String>,
configuration: Option<ClientConfiguration>,
fix_all: Option<bool>,
organize_imports: Option<bool>,
lint: Option<LintOptions>,
@ -306,11 +362,17 @@ impl ResolvedClientSettings {
),
editor_settings: ResolvedEditorSettings {
configuration: Self::resolve_optional(all_settings, |settings| {
settings
.configuration
.as_ref()
.and_then(|config_path| shellexpand::full(config_path).ok())
.map(|config_path| PathBuf::from(config_path.as_ref()))
settings.configuration.as_ref().and_then(|configuration| {
match ResolvedConfiguration::try_from(configuration) {
Ok(configuration) => Some(configuration),
Err(err) => {
tracing::error!(
"Failed to load settings from `configuration`: {err}"
);
None
}
}
})
}),
lint_preview: Self::resolve_optional(all_settings, |settings| {
settings.lint.as_ref()?.preview
@ -425,6 +487,10 @@ impl Default for InitializationOptions {
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use ruff_python_formatter::QuoteStyle;
use ruff_workspace::options::{
FormatOptions as RuffFormatOptions, LintCommonOptions, LintOptions,
};
use serde::de::DeserializeOwned;
#[cfg(not(windows))]
@ -445,6 +511,9 @@ mod tests {
const EMPTY_MULTIPLE_WORKSPACE_INIT_OPTIONS_FIXTURE: &str =
include_str!("../../resources/test/fixtures/settings/empty_multiple_workspace.json");
const INLINE_CONFIGURATION_FIXTURE: &str =
include_str!("../../resources/test/fixtures/settings/inline_configuration.json");
fn deserialize_fixture<T: DeserializeOwned>(content: &str) -> T {
serde_json::from_str(content).expect("test fixture JSON should deserialize")
}
@ -855,4 +924,48 @@ mod tests {
all_settings.set_preview(true);
assert_preview_all_settings(&all_settings, true);
}
#[test]
fn inline_configuration() {
let options: InitializationOptions = deserialize_fixture(INLINE_CONFIGURATION_FIXTURE);
let AllSettings {
global_settings,
workspace_settings: None,
} = AllSettings::from_init_options(options)
else {
panic!("Expected global settings only");
};
assert_eq!(
ResolvedClientSettings::global(&global_settings),
ResolvedClientSettings {
fix_all: true,
organize_imports: true,
lint_enable: true,
disable_rule_comment_enable: true,
fix_violation_enable: true,
show_syntax_errors: true,
editor_settings: ResolvedEditorSettings {
configuration: Some(ResolvedConfiguration::Inline(Options {
line_length: Some(LineLength::try_from(100).unwrap()),
lint: Some(LintOptions {
common: LintCommonOptions {
extend_select: Some(vec![RuleSelector::from_str("I001").unwrap()]),
..Default::default()
},
..Default::default()
}),
format: Some(RuffFormatOptions {
quote_style: Some(QuoteStyle::Single),
..Default::default()
}),
..Default::default()
})),
extend_select: Some(vec![RuleSelector::from_str("RUF001").unwrap()]),
..Default::default()
}
}
);
}
}