diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index 71890a0342c55..38802f09f1fdf 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -1,3 +1,4 @@ +use anyhow::anyhow; use std::path::PathBuf; use clap::{command, Parser}; @@ -12,6 +13,7 @@ use ruff_linter::settings::types::{ SerializationFormat, UnsafeFixes, }; use ruff_linter::{RuleParser, RuleSelector, RuleSelectorParser}; +use ruff_text_size::{TextRange, TextSize}; use ruff_workspace::configuration::{Configuration, RuleSelection}; use ruff_workspace::options::PycodestyleOptions; use ruff_workspace::resolver::ConfigurationTransformer; @@ -437,6 +439,28 @@ pub struct FormatCommand { preview: bool, #[clap(long, overrides_with("preview"), hide = true)] no_preview: bool, + + /// Format code starting at the given character offset (zero based). + /// + /// When specified, Ruff will try to only format the code after the specified offset but + /// it might be necessary to extend the start backwards, e.g. to the start of the logical line. + /// + /// Defaults to the start of the document. + /// + /// The option can only be used when formatting a single file. Range formatting of notebooks is unsupported. + #[arg(long)] + pub range_start: Option, + + /// Format code ending (exclusive) at the given character offset (zero based). + /// + /// When specified, Ruff will try to only format the code coming before the specified offset but + /// it might be necessary to extend the forward, e.g. to the end of the logical line. + /// + /// Defaults to the end of the document. + /// + /// The option can only be used when formatting a single file. Range formatting of notebooks is unsupported. + #[arg(long)] + pub range_end: Option, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] @@ -554,8 +578,19 @@ impl CheckCommand { impl FormatCommand { /// Partition the CLI into command-line arguments and configuration /// overrides. - pub fn partition(self) -> (FormatArguments, CliOverrides) { - ( + pub fn partition(self) -> anyhow::Result<(FormatArguments, CliOverrides)> { + if let (Some(start), Some(end)) = (self.range_start, self.range_end) { + if start > end { + return Err(anyhow!( + r#"The range `--range-start` must be smaller or equal to `--range-end`, but {start} > {end}. + Hint: Try switching the range's start and end values: `--range-start={end} --range-end={start}`"#, + )); + } + } + + let range = CharRange::new(self.range_start, self.range_end); + + Ok(( FormatArguments { check: self.check, diff: self.diff, @@ -564,6 +599,7 @@ impl FormatCommand { isolated: self.isolated, no_cache: self.no_cache, stdin_filename: self.stdin_filename, + range, }, CliOverrides { line_length: self.line_length, @@ -581,7 +617,7 @@ impl FormatCommand { // Unsupported on the formatter CLI, but required on `Overrides`. ..CliOverrides::default() }, - ) + )) } } @@ -627,6 +663,76 @@ pub struct FormatArguments { pub files: Vec, pub isolated: bool, pub stdin_filename: Option, + pub range: Option, +} + +/// A text range specified in character offsets. +#[derive(Copy, Clone, Debug)] +pub enum CharRange { + /// A range that covers the content from the given start character offset up to the end of the file. + StartsAt(usize), + + /// A range that covers the content from the start of the file up to, but excluding the given end character offset. + EndsAt(usize), + + /// Range that covers the content between the given start and end character offsets. + Between(usize, usize), +} + +impl CharRange { + /// Creates a new [`CharRange`] from the given start and end character offsets. + /// + /// Returns `None` if both `start` and `end` are `None`. + /// + /// # Panics + /// + /// If both `start` and `end` are `Some` and `start` is greater than `end`. + pub(super) fn new(start: Option, end: Option) -> Option { + match (start, end) { + (Some(start), Some(end)) => { + assert!(start <= end); + + Some(CharRange::Between(start, end)) + } + (Some(start), None) => Some(CharRange::StartsAt(start)), + (None, Some(end)) => Some(CharRange::EndsAt(end)), + (None, None) => None, + } + } + + /// Converts the range specified in character offsets to a byte offsets specific for `source`. + /// + /// Returns an empty range starting at `source.len()` if `start` is passed the end of `source`. + /// + /// # Panics + /// + /// If either the start or end offset point to a byte offset larger than `u32::MAX`. + pub(super) fn to_text_range(self, source: &str) -> TextRange { + let (start_char, end_char) = match self { + CharRange::StartsAt(offset) => (offset, None), + CharRange::EndsAt(offset) => (0usize, Some(offset)), + CharRange::Between(start, end) => (start, Some(end)), + }; + + let start_offset = source + .char_indices() + .nth(start_char) + .map_or(source.len(), |(offset, _)| offset); + + let end_offset = end_char + .and_then(|end_char| { + source[start_offset..] + .char_indices() + .nth(end_char - start_char) + .map(|(relative_offset, _)| start_offset + relative_offset) + }) + .unwrap_or(source.len()); + + TextRange::new( + TextSize::try_from(start_offset).unwrap(), + TextSize::try_from(end_offset).unwrap(), + ) + } } /// CLI settings that function as configuration overrides. diff --git a/crates/ruff/src/cache.rs b/crates/ruff/src/cache.rs index f71d26244a99e..eeaa26a0d442f 100644 --- a/crates/ruff/src/cache.rs +++ b/crates/ruff/src/cache.rs @@ -1050,6 +1050,7 @@ mod tests { &self.settings.formatter, PySourceType::Python, FormatMode::Write, + None, Some(cache), ) } diff --git a/crates/ruff/src/commands/format.rs b/crates/ruff/src/commands/format.rs index f8ecadaf3f24f..bcbab5254031e 100644 --- a/crates/ruff/src/commands/format.rs +++ b/crates/ruff/src/commands/format.rs @@ -23,12 +23,12 @@ use ruff_linter::rules::flake8_quotes::settings::Quote; use ruff_linter::source_kind::{SourceError, SourceKind}; use ruff_linter::warn_user_once; use ruff_python_ast::{PySourceType, SourceType}; -use ruff_python_formatter::{format_module_source, FormatModuleError, QuoteStyle}; +use ruff_python_formatter::{format_module_source, format_range, FormatModuleError, QuoteStyle}; use ruff_text_size::{TextLen, TextRange, TextSize}; use ruff_workspace::resolver::{match_exclusion, python_files_in_path, ResolvedFile, Resolver}; use ruff_workspace::FormatterSettings; -use crate::args::{CliOverrides, FormatArguments}; +use crate::args::{CharRange, CliOverrides, FormatArguments}; use crate::cache::{Cache, FileCacheKey, PackageCacheMap, PackageCaches}; use crate::panic::{catch_unwind, PanicError}; use crate::resolve::resolve; @@ -77,6 +77,13 @@ pub(crate) fn format( return Ok(ExitStatus::Success); } + if cli.range.is_some() && paths.len() > 1 { + return Err(anyhow::anyhow!( + "The `--range` option is only supported when formatting a single file but the specified paths resolve to {} files.", + paths.len() + )); + } + warn_incompatible_formatter_settings(&resolver); // Discover the package root for each Python file. @@ -139,7 +146,14 @@ pub(crate) fn format( Some( match catch_unwind(|| { - format_path(path, &settings.formatter, source_type, mode, cache) + format_path( + path, + &settings.formatter, + source_type, + mode, + cli.range, + cache, + ) }) { Ok(inner) => inner.map(|result| FormatPathResult { path: resolved_file.path().to_path_buf(), @@ -226,6 +240,7 @@ pub(crate) fn format_path( settings: &FormatterSettings, source_type: PySourceType, mode: FormatMode, + range: Option, cache: Option<&Cache>, ) -> Result { if let Some(cache) = cache { @@ -250,8 +265,12 @@ pub(crate) fn format_path( } }; + // Don't write back to the cache if formatting a range. + let write_cache = cache.filter(|_| range.is_none()); + // Format the source. - let format_result = match format_source(&unformatted, source_type, Some(path), settings)? { + let format_result = match format_source(&unformatted, source_type, Some(path), settings, range)? + { FormattedSource::Formatted(formatted) => match mode { FormatMode::Write => { let mut writer = File::create(path).map_err(|err| { @@ -261,7 +280,7 @@ pub(crate) fn format_path( .write(&mut writer) .map_err(|err| FormatCommandError::Write(Some(path.to_path_buf()), err))?; - if let Some(cache) = cache { + if let Some(cache) = write_cache { if let Ok(cache_key) = FileCacheKey::from_path(path) { let relative_path = cache .relative_path(path) @@ -279,7 +298,7 @@ pub(crate) fn format_path( }, }, FormattedSource::Unchanged => { - if let Some(cache) = cache { + if let Some(cache) = write_cache { if let Ok(cache_key) = FileCacheKey::from_path(path) { let relative_path = cache .relative_path(path) @@ -319,12 +338,30 @@ pub(crate) fn format_source( source_type: PySourceType, path: Option<&Path>, settings: &FormatterSettings, + range: Option, ) -> Result { match &source_kind { SourceKind::Python(unformatted) => { let options = settings.to_format_options(source_type, unformatted); - let formatted = format_module_source(unformatted, options).map_err(|err| { + let formatted = if let Some(range) = range { + let byte_range = range.to_text_range(unformatted); + format_range(unformatted, byte_range, options).map(|formatted_range| { + let mut formatted = unformatted.to_string(); + formatted.replace_range( + std::ops::Range::::from(formatted_range.source_range()), + formatted_range.as_code(), + ); + + formatted + }) + } else { + // Using `Printed::into_code` requires adding `ruff_formatter` as a direct dependency, and I suspect that Rust can optimize the closure away regardless. + #[allow(clippy::redundant_closure_for_method_calls)] + format_module_source(unformatted, options).map(|formatted| formatted.into_code()) + }; + + let formatted = formatted.map_err(|err| { if let FormatModuleError::ParseError(err) = err { DisplayParseError::from_source_kind( err, @@ -337,7 +374,6 @@ pub(crate) fn format_source( } })?; - let formatted = formatted.into_code(); if formatted.len() == unformatted.len() && formatted == *unformatted { Ok(FormattedSource::Unchanged) } else { @@ -349,6 +385,12 @@ pub(crate) fn format_source( return Ok(FormattedSource::Unchanged); } + if range.is_some() { + return Err(FormatCommandError::RangeFormatNotebook( + path.map(Path::to_path_buf), + )); + } + let options = settings.to_format_options(source_type, notebook.source_code()); let mut output: Option = None; @@ -589,6 +631,7 @@ pub(crate) enum FormatCommandError { Format(Option, FormatModuleError), Write(Option, SourceError), Diff(Option, io::Error), + RangeFormatNotebook(Option), } impl FormatCommandError { @@ -606,7 +649,8 @@ impl FormatCommandError { | Self::Read(path, _) | Self::Format(path, _) | Self::Write(path, _) - | Self::Diff(path, _) => path.as_deref(), + | Self::Diff(path, _) + | Self::RangeFormatNotebook(path) => path.as_deref(), } } } @@ -628,9 +672,10 @@ impl Display for FormatCommandError { } else { write!( f, - "{} {}", - "Encountered error:".bold(), - err.io_error() + "{header} {error}", + header = "Encountered error:".bold(), + error = err + .io_error() .map_or_else(|| err.to_string(), std::string::ToString::to_string) ) } @@ -648,7 +693,7 @@ impl Display for FormatCommandError { ":".bold() ) } else { - write!(f, "{}{} {err}", "Failed to read".bold(), ":".bold()) + write!(f, "{header} {err}", header = "Failed to read:".bold()) } } Self::Write(path, err) => { @@ -661,7 +706,7 @@ impl Display for FormatCommandError { ":".bold() ) } else { - write!(f, "{}{} {err}", "Failed to write".bold(), ":".bold()) + write!(f, "{header} {err}", header = "Failed to write:".bold()) } } Self::Format(path, err) => { @@ -674,7 +719,7 @@ impl Display for FormatCommandError { ":".bold() ) } else { - write!(f, "{}{} {err}", "Failed to format".bold(), ":".bold()) + write!(f, "{header} {err}", header = "Failed to format:".bold()) } } Self::Diff(path, err) => { @@ -689,9 +734,25 @@ impl Display for FormatCommandError { } else { write!( f, - "{}{} {err}", - "Failed to generate diff".bold(), - ":".bold() + "{header} {err}", + header = "Failed to generate diff:".bold(), + ) + } + } + Self::RangeFormatNotebook(path) => { + if let Some(path) = path { + write!( + f, + "{header}{path}{colon} Range formatting isn't supported for notebooks.", + header = "Failed to format ".bold(), + path = fs::relativize_path(path).bold(), + colon = ":".bold() + ) + } else { + write!( + f, + "{header} Range formatting isn't supported for notebooks", + header = "Failed to format:".bold() ) } } diff --git a/crates/ruff/src/commands/format_stdin.rs b/crates/ruff/src/commands/format_stdin.rs index 41695ae8b5443..5195e3a483f29 100644 --- a/crates/ruff/src/commands/format_stdin.rs +++ b/crates/ruff/src/commands/format_stdin.rs @@ -9,7 +9,7 @@ use ruff_python_ast::{PySourceType, SourceType}; use ruff_workspace::resolver::{match_exclusion, python_file_at_path, Resolver}; use ruff_workspace::FormatterSettings; -use crate::args::{CliOverrides, FormatArguments}; +use crate::args::{CharRange, CliOverrides, FormatArguments}; use crate::commands::format::{ format_source, warn_incompatible_formatter_settings, FormatCommandError, FormatMode, FormatResult, FormattedSource, @@ -69,7 +69,7 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &CliOverrides) -> R }; // Format the file. - match format_source_code(path, settings, source_type, mode) { + match format_source_code(path, cli.range, settings, source_type, mode) { Ok(result) => match mode { FormatMode::Write => Ok(ExitStatus::Success), FormatMode::Check | FormatMode::Diff => { @@ -90,6 +90,7 @@ pub(crate) fn format_stdin(cli: &FormatArguments, overrides: &CliOverrides) -> R /// Format source code read from `stdin`. fn format_source_code( path: Option<&Path>, + range: Option, settings: &FormatterSettings, source_type: PySourceType, mode: FormatMode, @@ -107,7 +108,7 @@ fn format_source_code( }; // Format the source. - let formatted = format_source(&source_kind, source_type, path, settings)?; + let formatted = format_source(&source_kind, source_type, path, settings, range)?; match &formatted { FormattedSource::Formatted(formatted) => match mode { diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index f8f99505b3c91..a2eb132880a7e 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -204,7 +204,7 @@ pub fn run( } fn format(args: FormatCommand, log_level: LogLevel) -> Result { - let (cli, overrides) = args.partition(); + let (cli, overrides) = args.partition()?; if is_stdin(&cli.files, cli.stdin_filename.as_deref()) { commands::format_stdin::format_stdin(&cli, &overrides) diff --git a/crates/ruff/tests/format.rs b/crates/ruff/tests/format.rs index d7a255fb555ac..ec141ca52d75a 100644 --- a/crates/ruff/tests/format.rs +++ b/crates/ruff/tests/format.rs @@ -1538,3 +1538,163 @@ include = ["*.ipy"] "###); Ok(()) } + +#[test] +fn range_formatting() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range-start=8", "--range-end=15"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Shouldn't format this" ) + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + def foo( + arg1, + arg2, + ): + print("Shouldn't format this" ) + + + ----- stderr ----- + "###); +} + +#[test] +fn range_formatting_multiple_files() -> std::io::Result<()> { + let tempdir = TempDir::new()?; + let file1 = tempdir.path().join("file1.py"); + + fs::write( + &file1, + r#" +def file1(arg1, arg2,): + print("Shouldn't format this" ) + +"#, + )?; + + let file2 = tempdir.path().join("file2.py"); + + fs::write( + &file2, + r#" +def file2(arg1, arg2,): + print("Shouldn't format this" ) + +"#, + )?; + + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--range-start=8", "--range-end=15"]) + .arg(file1) + .arg(file2), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + ruff failed + Cause: The `--range` option is only supported when formatting a single file but the specified paths resolve to 2 files. + "###); + + Ok(()) +} + +#[test] +fn range_formatting_out_of_bounds() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range-start=100", "--range-end=200"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Shouldn't format this" ) + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + def foo(arg1, arg2,): + print("Shouldn't format this" ) + + + ----- stderr ----- + "###); +} + +#[test] +fn range_start_larger_than_end() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range-start=50", "--range-end=10"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Shouldn't format this" ) + +"#), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + ruff failed + Cause: The range `--range-start` must be smaller or equal to `--range-end`, but 50 > 10. + Hint: Try switching the range's start and end values: `--range-start=10 --range-end=50` + "###); +} + +#[test] +fn range_formatting_notebook() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--no-cache", "--stdin-filename", "main.ipynb", "--range-start=8", "--range-end=15"]) + .arg("-") + .pass_stdin(r#" +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "ad6f36d9-4b7d-4562-8d00-f15a0f1fbb6d", + "metadata": {}, + "outputs": [], + "source": [ + "x=1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} +"#), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to format main.ipynb: Range formatting isn't supported for notebooks. + "###); +} diff --git a/crates/ruff_dev/src/format_dev.rs b/crates/ruff_dev/src/format_dev.rs index e692d0ecee587..e0524a3533318 100644 --- a/crates/ruff_dev/src/format_dev.rs +++ b/crates/ruff_dev/src/format_dev.rs @@ -42,9 +42,7 @@ fn parse_cli(dirs: &[PathBuf]) -> anyhow::Result<(FormatArguments, CliOverrides) let args_matches = FormatCommand::command() .no_binary_name(true) .get_matches_from(dirs); - let arguments: FormatCommand = FormatCommand::from_arg_matches(&args_matches)?; - let (cli, overrides) = arguments.partition(); - Ok((cli, overrides)) + FormatCommand::from_arg_matches(&args_matches)?.partition() } /// Find the [`PyprojectConfig`] to use for formatting. diff --git a/docs/configuration.md b/docs/configuration.md index 2114f2f6e774b..21f71975bd603 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -649,8 +649,13 @@ Options: --preview Enable preview mode; enables unstable formatting. Use `--no-preview` to disable + --range-start + Format code starting at the given character offset (zero based) + --range-end + Format code ending (exclusive) at the given character offset (zero + based) -h, --help - Print help + Print help (see more with '--help') Miscellaneous: -n, --no-cache