From a6510f770a0d5c18800b6cb12ac99a5350b17dfa Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Mon, 27 Nov 2023 10:21:19 -0500 Subject: [PATCH] config: add new 'docstring-code-format' and 'docstring-code-line-length' knobs This commit does the plumbing to make a new formatting option, 'docstring-code-format', available in the configuration for end users. It is disabled by default (opt-in). It is opt-in at least initially to reflect a conservative posture. The intent is to make it opt-out at some point in the future. This also adds a compansion option, 'docstring-code-line-length', that permits setting a line length for reformatted code examples that is distinct from the global setting. Its default value is 'dynamic', which means reformatted code will respect the global line width, regardless of the indent level of the enclosing docstring. --- crates/ruff_cli/tests/format.rs | 93 ++++++++++++ crates/ruff_python_formatter/src/options.rs | 6 + crates/ruff_workspace/src/configuration.rs | 24 +++- crates/ruff_workspace/src/options.rs | 149 +++++++++++++++++++- crates/ruff_workspace/src/settings.rs | 12 +- docs/configuration.md | 28 ++++ docs/formatter.md | 101 ++++++++++++- ruff.schema.json | 34 +++++ 8 files changed, 441 insertions(+), 6 deletions(-) diff --git a/crates/ruff_cli/tests/format.rs b/crates/ruff_cli/tests/format.rs index e9970645d7b671..cd3e14629899e7 100644 --- a/crates/ruff_cli/tests/format.rs +++ b/crates/ruff_cli/tests/format.rs @@ -139,6 +139,99 @@ if condition: Ok(()) } +#[test] +fn docstring_options() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("ruff.toml"); + fs::write( + &ruff_toml, + r#" +[format] +docstring-code-format = true +docstring-code-line-length = 20 +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--config"]) + .arg(&ruff_toml) + .arg("-") + .pass_stdin(r#" +def f(x): + ''' + Something about `f`. And an example: + + .. code-block:: python + + foo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) + + Another example: + + ```py + foo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) + ``` + + And another: + + >>> foo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) + ''' + pass +"#), @r###" +success: true +exit_code: 0 +----- stdout ----- +def f(x): + """ + Something about `f`. And an example: + + .. code-block:: python + + ( + foo, + bar, + quux, + ) = this_is_a_long_line( + lion, + hippo, + lemur, + bear, + ) + + Another example: + + ```py + ( + foo, + bar, + quux, + ) = this_is_a_long_line( + lion, + hippo, + lemur, + bear, + ) + ``` + + And another: + + >>> ( + ... foo, + ... bar, + ... quux, + ... ) = this_is_a_long_line( + ... lion, + ... hippo, + ... lemur, + ... bear, + ... ) + """ + pass + +----- stderr ----- +"###); + Ok(()) +} + #[test] fn mixed_line_endings() -> Result<()> { let tempdir = TempDir::new()?; diff --git a/crates/ruff_python_formatter/src/options.rs b/crates/ruff_python_formatter/src/options.rs index 109b90a8828558..4f637dca9ee107 100644 --- a/crates/ruff_python_formatter/src/options.rs +++ b/crates/ruff_python_formatter/src/options.rs @@ -175,6 +175,12 @@ impl PyFormatOptions { self } + #[must_use] + pub fn with_docstring_code_line_width(mut self, line_width: DocstringCodeLineWidth) -> Self { + self.docstring_code_line_width = line_width; + self + } + #[must_use] pub fn with_preview(mut self, preview: PreviewMode) -> Self { self.preview = preview; diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index 6b3e2abbc45a13..fe95418e69c437 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -34,7 +34,9 @@ use ruff_linter::settings::{ use ruff_linter::{ fs, warn_user, warn_user_once, warn_user_once_by_id, RuleSelector, RUFF_PKG_VERSION, }; -use ruff_python_formatter::{MagicTrailingComma, QuoteStyle}; +use ruff_python_formatter::{ + DocstringCode, DocstringCodeLineWidth, MagicTrailingComma, QuoteStyle, +}; use crate::options::{ Flake8AnnotationsOptions, Flake8BanditOptions, Flake8BugbearOptions, Flake8BuiltinsOptions, @@ -189,6 +191,12 @@ impl Configuration { magic_trailing_comma: format .magic_trailing_comma .unwrap_or(format_defaults.magic_trailing_comma), + docstring_code_format: format + .docstring_code_format + .unwrap_or(format_defaults.docstring_code_format), + docstring_code_line_width: format + .docstring_code_line_width + .unwrap_or(format_defaults.docstring_code_line_width), }; let lint = self.lint; @@ -1020,6 +1028,8 @@ pub struct FormatConfiguration { pub quote_style: Option, pub magic_trailing_comma: Option, pub line_ending: Option, + pub docstring_code_format: Option, + pub docstring_code_line_width: Option, } impl FormatConfiguration { @@ -1046,6 +1056,14 @@ impl FormatConfiguration { } }), line_ending: options.line_ending, + docstring_code_format: options.docstring_code_format.map(|yes| { + if yes { + DocstringCode::Enabled + } else { + DocstringCode::Disabled + } + }), + docstring_code_line_width: options.docstring_code_line_length, }) } @@ -1059,6 +1077,10 @@ impl FormatConfiguration { quote_style: self.quote_style.or(other.quote_style), magic_trailing_comma: self.magic_trailing_comma.or(other.magic_trailing_comma), line_ending: self.line_ending.or(other.line_ending), + docstring_code_format: self.docstring_code_format.or(other.docstring_code_format), + docstring_code_line_width: self + .docstring_code_line_width + .or(other.docstring_code_line_width), } } } diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index 10e5c6717696ef..af8668b31ff0b4 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -27,7 +27,7 @@ use ruff_linter::settings::types::{ }; use ruff_linter::{warn_user_once, RuleSelector}; use ruff_macros::{CombineOptions, OptionsMetadata}; -use ruff_python_formatter::QuoteStyle; +use ruff_python_formatter::{DocstringCodeLineWidth, QuoteStyle}; use crate::settings::LineEnding; @@ -2896,6 +2896,153 @@ pub struct FormatOptions { "# )] pub line_ending: Option, + + /// Whether to format code snippets in docstrings. + /// + /// When this is enabled, Python code examples within docstrings is + /// automatically reformatted. + /// + /// For example, when this is enabled, the following code: + /// + /// ```python + /// def f(x): + /// """ + /// Something about `f`. And an example in doctest format: + /// + /// >>> f( x ) + /// + /// Markdown is also supported: + /// + /// ```py + /// f( x ) + /// ``` + /// + /// As are reStructuredText literal blocks:: + /// + /// f( x ) + /// + /// + /// And reStructuredText code blocks: + /// + /// .. code-block:: python + /// + /// f( x ) + /// """ + /// pass + /// ``` + /// + /// ... will be reformatted (assuming the rest of the options are set to + /// their defaults) as: + /// + /// ```python + /// def f(x): + /// """ + /// Something about `f`. And an example in doctest format: + /// + /// >>> f(x) + /// + /// Markdown is also supported: + /// + /// ```py + /// f(x) + /// ``` + /// + /// As are reStructuredText literal blocks:: + /// + /// f(x) + /// + /// + /// And reStructuredText code blocks: + /// + /// .. code-block:: python + /// + /// f(x) + /// """ + /// pass + /// ``` + /// + /// If a code snippt in a docstring contains invalid Python code or if the + /// formatter would otherwise write invalid Python code, then the code + /// example is ignored by the formatter and kept as-is. + /// + /// Currently, doctest, Markdown, reStructuredText literal blocks, and + /// reStructuredText code blocks are all supported and automatically + /// recognized. + #[option( + default = "false", + value_type = "bool", + example = r#" + # Enable reformatting of code snippets in docstrings. + docstring-code-format = true + "# + )] + pub docstring_code_format: Option, + + /// Set the line length used when formatting code snippets in docstrings. + /// + /// This only has an effect when the `docstring-code-format` setting is + /// enabled. + /// + /// The default value for this setting is `"dynamic"`, which has the effect + /// of ensuring that any reformatted code examples in docstrings adhere to + /// the global line length configuration that is used for the surrounding + /// Python code. The point of this setting is that it takes the indentation + /// of the docstring into account when reformatting code examples. + /// + /// Alternatively, this can be set to a fixed integer, which will result + /// in the same line length limit being applied to all reformatted code + /// examples in docstrings. When set to a fixed integer, the indent of the + /// docstring is not taken into account. That is, this may result in lines + /// in the reformatted code example that exceed the globally configured + /// line length limit. + /// + /// For example, when this is set to `20` and `docstring-code-format` is + /// enabled, then this code: + /// + /// ```python + /// def f(x): + /// ''' + /// Something about `f`. And an example: + /// + /// .. code-block:: python + /// + /// foo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) + /// ''' + /// pass + /// ``` + /// + /// ... will be reformatted (assuming the rest of the options are set + /// to their defaults) as: + /// + /// ```python + /// def f(x): + /// """ + /// Something about `f`. And an example: + /// + /// .. code-block:: python + /// + /// ( + /// foo, + /// bar, + /// quux, + /// ) = this_is_a_long_line( + /// lion, + /// hippo, + /// lemur, + /// bear, + /// ) + /// """ + /// pass + /// ``` + #[option( + default = r#""dynamic""#, + value_type = r#"int | "dynamic""#, + example = r#" + # Format all docstring code snippets with a line length of 60. + docstring-code-line-length = 60 + "# + )] + pub docstring_code_line_length: Option, } #[cfg(test)] diff --git a/crates/ruff_workspace/src/settings.rs b/crates/ruff_workspace/src/settings.rs index 982732e4873179..8ee030ea3b42a1 100644 --- a/crates/ruff_workspace/src/settings.rs +++ b/crates/ruff_workspace/src/settings.rs @@ -5,7 +5,10 @@ use ruff_linter::settings::types::{FilePattern, FilePatternSet, SerializationFor use ruff_linter::settings::LinterSettings; use ruff_macros::CacheKey; use ruff_python_ast::PySourceType; -use ruff_python_formatter::{MagicTrailingComma, PreviewMode, PyFormatOptions, QuoteStyle}; +use ruff_python_formatter::{ + DocstringCode, DocstringCodeLineWidth, MagicTrailingComma, PreviewMode, PyFormatOptions, + QuoteStyle, +}; use ruff_source_file::find_newline; use std::path::{Path, PathBuf}; @@ -124,6 +127,9 @@ pub struct FormatterSettings { pub magic_trailing_comma: MagicTrailingComma, pub line_ending: LineEnding, + + pub docstring_code_format: DocstringCode, + pub docstring_code_line_width: DocstringCodeLineWidth, } impl FormatterSettings { @@ -157,6 +163,8 @@ impl FormatterSettings { .with_preview(self.preview) .with_line_ending(line_ending) .with_line_width(self.line_width) + .with_docstring_code(self.docstring_code_format) + .with_docstring_code_line_width(self.docstring_code_line_width) } } @@ -173,6 +181,8 @@ impl Default for FormatterSettings { indent_width: default_options.indent_width(), quote_style: default_options.quote_style(), magic_trailing_comma: default_options.magic_trailing_comma(), + docstring_code_format: default_options.docstring_code(), + docstring_code_line_width: default_options.docstring_code_line_width(), } } } diff --git a/docs/configuration.md b/docs/configuration.md index 08a33ffad2b348..bedd12300fa24b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,20 @@ If left unspecified, Ruff's default configuration is equivalent to: # Like Black, automatically detect the appropriate line ending. line-ending = "auto" + + # Enable auto-formatting of code examples in docstrings. Markdown, + # reStructuredText code/literal blocks and doctests are all supported. + # + # This is currently disabled by default, but it is planned for this + # to be opt-out in the future. + docstring-code-format = false + + # Set the line length limit used when formatting code snippets in + # docstrings. + # + # This only has an effect when the `docstring-code-format` setting is + # enabled. + docstring-code-line-length = "dynamic" ``` === "ruff.toml" @@ -134,6 +148,20 @@ If left unspecified, Ruff's default configuration is equivalent to: # Like Black, automatically detect the appropriate line ending. line-ending = "auto" + + # Enable auto-formatting of code examples in docstrings. Markdown, + # reStructuredText code/literal blocks and doctests are all supported. + # + # This is currently disabled by default, but it is planned for this + # to be opt-out in the future. + docstring-code-format = false + + # Set the line length limit used when formatting code snippets in + # docstrings. + # + # This only has an effect when the `docstring-code-format` setting is + # enabled. + docstring-code-line-length = "dynamic" ``` As an example, the following would configure Ruff to: diff --git a/docs/formatter.md b/docs/formatter.md index 14d35e94a2dcd0..8c8537946862d2 100644 --- a/docs/formatter.md +++ b/docs/formatter.md @@ -103,10 +103,12 @@ Going forward, the Ruff Formatter will support Black's preview style under Ruff' ## Configuration The Ruff Formatter exposes a small set of configuration options, some of which are also supported -by Black (like line width), some of which are unique to Ruff (like quote and indentation style). +by Black (like line width), some of which are unique to Ruff (like quote, indentation style and +formatting code examples in docstrings). -For example, to configure the formatter to use single quotes, a line width of 100, and -tab indentation, add the following to your configuration file: +For example, to configure the formatter to use single quotes, format code +examples in docstrings, a line width of 100, and tab indentation, add the +following to your configuration file: === "pyproject.toml" @@ -117,6 +119,7 @@ tab indentation, add the following to your configuration file: [tool.ruff.format] quote-style = "single" indent-style = "tab" + docstring-code-format = true ``` === "ruff.toml" @@ -127,6 +130,7 @@ tab indentation, add the following to your configuration file: [format] quote-style = "single" indent-style = "tab" + docstring-code-format = true ``` @@ -137,6 +141,97 @@ Given the focus on Black compatibility (and unlike formatters like [YAPF](https: Ruff does not currently expose any configuration options to modify core formatting behavior outside of these trivia-related settings. +## Docstring formatting + +The Ruff formatter provides an opt-in feature for automatically formatting +Python code examples in docstrings. The Ruff formatter currently recognizes +code examples in the following formats: + +* The Python [doctest] format. +* CommonMark [fenced code blocks] with the following info strings: `python`, +`py`, `python3`, or `py3`. Fenced code blocks without an info string are +assumed to be Python code examples and also formatted. +* reStructuredText [literal blocks]. While literal blocks may contain things +other than Python, this is meant to reflect a long-standing convention in the +Python ecosystem where literal blocks often contain Python code. +* reStructuredText [`code-block` and `sourcecode` directives]. As with +Markdown, the language names recognized for Python are `python`, `py`, +`python3`, or `py3`. + +If a code example is recognized and treated as Python, the Ruff formatter will +automatically skip it if the code does not parse as valid Python or if the +reformatted code would produce an invalid Python program. + +Users may also configure the line length limit used for reformatting Python +code examples in docstrings. The default is a special value, `dynamic`, which +instructs the formatter to respect the line length limit setting for the +surrounding Python code. The `dynamic` setting ensures that even when code +examples are found inside indented docstrings, the line length limit configured +for the surrounding Python code will not be exceeded. Users may also configure +a fixed line length limit for code examples in docstrings. + +For example, this configuration shows how to enable docstring code formatting +with a fixed line length limit: + +=== "pyproject.toml" + + ```toml + [tool.ruff.format] + docstring-code-format = true + docstring-code-line-length = 20 + ``` + +=== "ruff.toml" + + ```toml + [format] + docstring-code-format = true + docstring-code-line-length = 20 + ``` + +With the above configuration, this code: + +```python +def f(x): + ''' + Something about `f`. And an example: + + .. code-block:: python + + foo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) + ''' + pass +``` + +... will be reformatted (assuming the rest of the options are set +to their defaults) as: + +```python +def f(x): + """ + Something about `f`. And an example: + + .. code-block:: python + + ( + foo, + bar, + quux, + ) = this_is_a_long_line( + lion, + hippo, + lemur, + bear, + ) + """ + pass +``` + +[doctest]: https://docs.python.org/3/library/doctest.html +[fenced code blocks]: https://spec.commonmark.org/0.30/#fenced-code-blocks +[literal blocks]: https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#literal-blocks +[`code-block` and `sourcecode` directives]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#directive-code-block + ## Format suppression Like Black, Ruff supports `# fmt: on`, `# fmt: off`, and `# fmt: skip` pragma comments, which can diff --git a/ruff.schema.json b/ruff.schema.json index cbff63adcd8077..d1269dd2a2af65 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -747,6 +747,16 @@ } ] }, + "DocstringCodeLineWidth": { + "anyOf": [ + { + "$ref": "#/definitions/LineWidth" + }, + { + "type": "null" + } + ] + }, "Flake8AnnotationsOptions": { "type": "object", "properties": { @@ -1241,6 +1251,24 @@ "description": "Experimental: Configures how `ruff format` formats your code.\n\nPlease provide feedback in [this discussion](https://github.com/astral-sh/ruff/discussions/7310).", "type": "object", "properties": { + "docstring-code-format": { + "description": "Whether to format code snippets in docstrings.\n\nWhen this is enabled, Python code examples within docstrings is automatically reformatted.\n\nFor example, when this is enabled, the following code:\n\n```python def f(x): \"\"\" Something about `f`. And an example in doctest format:\n\n>>> f( x )\n\nMarkdown is also supported:\n\n```py f( x ) ```\n\nAs are reStructuredText literal blocks::\n\nf( x )\n\nAnd reStructuredText code blocks:\n\n.. code-block:: python\n\nf( x ) \"\"\" pass ```\n\n... will be reformatted (assuming the rest of the options are set to their defaults) as:\n\n```python def f(x): \"\"\" Something about `f`. And an example in doctest format:\n\n>>> f(x)\n\nMarkdown is also supported:\n\n```py f(x) ```\n\nAs are reStructuredText literal blocks::\n\nf(x)\n\nAnd reStructuredText code blocks:\n\n.. code-block:: python\n\nf(x) \"\"\" pass ```\n\nIf a code snippt in a docstring contains invalid Python code or if the formatter would otherwise write invalid Python code, then the code example is ignored by the formatter and kept as-is.\n\nCurrently, doctest, Markdown, reStructuredText literal blocks, and reStructuredText code blocks are all supported and automatically recognized.", + "type": [ + "boolean", + "null" + ] + }, + "docstring-code-line-length": { + "description": "Set the line length used when formatting code snippets in docstrings.\n\nThis only has an effect when the `docstring-code-format` setting is enabled.\n\nThe default value for this setting is `\"dynamic\"`, which has the effect of ensuring that any reformatted code examples in docstrings adhere to the global line length configuration that is used for the surrounding Python code. The point of this setting is that it takes the indentation of the docstring into account when reformatting code examples.\n\nAlternatively, this can be set to a fixed integer, which will result in the same line length limit being applied to all reformatted code examples in docstrings. When set to a fixed integer, the indent of the docstring is not taken into account. That is, this may result in lines in the reformatted code example that exceed the globally configured line length limit.\n\nFor example, when this is set to `20` and `docstring-code-format` is enabled, then this code:\n\n```python def f(x): ''' Something about `f`. And an example:\n\n.. code-block:: python\n\nfoo, bar, quux = this_is_a_long_line(lion, hippo, lemur, bear) ''' pass ```\n\n... will be reformatted (assuming the rest of the options are set to their defaults) as:\n\n```python def f(x): \"\"\" Something about `f`. And an example:\n\n.. code-block:: python\n\n( foo, bar, quux, ) = this_is_a_long_line( lion, hippo, lemur, bear, ) \"\"\" pass ```", + "anyOf": [ + { + "$ref": "#/definitions/DocstringCodeLineWidth" + }, + { + "type": "null" + } + ] + }, "exclude": { "description": "A list of file patterns to exclude from formatting in addition to the files excluded globally (see [`exclude`](#exclude), and [`extend-exclude`](#extend-exclude)).\n\nExclusions are based on globs, and can be either:\n\n- Single-path patterns, like `.mypy_cache` (to exclude any directory named `.mypy_cache` in the tree), `foo.py` (to exclude any file named `foo.py`), or `foo_*.py` (to exclude any file matching `foo_*.py` ). - Relative patterns, like `directory/foo.py` (to exclude that specific file) or `directory/*.py` (to exclude any Python files in `directory`). Note that these paths are relative to the project root (e.g., the directory containing your `pyproject.toml`).\n\nFor more information on the glob syntax, refer to the [`globset` documentation](https://docs.rs/globset/latest/globset/#syntax).", "type": [ @@ -1645,6 +1673,12 @@ "maximum": 320.0, "minimum": 1.0 }, + "LineWidth": { + "description": "The maximum visual width to which the formatter should try to limit a line.", + "type": "integer", + "format": "uint16", + "minimum": 1.0 + }, "LintOptions": { "description": "Experimental section to configure Ruff's linting. This new section will eventually replace the top-level linting options.\n\nOptions specified in the `lint` section take precedence over the top-level settings.", "type": "object",