Skip to content

Commit

Permalink
Add --range-start and --range-end options to ruff format
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaReiser committed Jan 31, 2024
1 parent ce14f4d commit 53fb8a4
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 29 deletions.
112 changes: 109 additions & 3 deletions crates/ruff/src/args.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use anyhow::anyhow;
use std::path::PathBuf;

use clap::{command, Parser};
Expand All @@ -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;
Expand Down Expand Up @@ -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<usize>,

/// 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<usize>,
}

#[derive(Debug, Clone, Copy, clap::ValueEnum)]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -581,7 +617,7 @@ impl FormatCommand {
// Unsupported on the formatter CLI, but required on `Overrides`.
..CliOverrides::default()
},
)
))
}
}

Expand Down Expand Up @@ -627,6 +663,76 @@ pub struct FormatArguments {
pub files: Vec<PathBuf>,
pub isolated: bool,
pub stdin_filename: Option<PathBuf>,
pub range: Option<CharRange>,
}

/// 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<usize>, end: Option<usize>) -> Option<Self> {
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.
Expand Down
1 change: 1 addition & 0 deletions crates/ruff/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ mod tests {
&self.settings.formatter,
PySourceType::Python,
FormatMode::Write,
None,
Some(cache),
)
}
Expand Down
97 changes: 79 additions & 18 deletions crates/ruff/src/commands/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -226,6 +240,7 @@ pub(crate) fn format_path(
settings: &FormatterSettings,
source_type: PySourceType,
mode: FormatMode,
range: Option<CharRange>,
cache: Option<&Cache>,
) -> Result<FormatResult, FormatCommandError> {
if let Some(cache) = cache {
Expand All @@ -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| {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -319,12 +338,30 @@ pub(crate) fn format_source(
source_type: PySourceType,
path: Option<&Path>,
settings: &FormatterSettings,
range: Option<CharRange>,
) -> Result<FormattedSource, FormatCommandError> {
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::<usize>::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,
Expand All @@ -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 {
Expand All @@ -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<String> = None;
Expand Down Expand Up @@ -589,6 +631,7 @@ pub(crate) enum FormatCommandError {
Format(Option<PathBuf>, FormatModuleError),
Write(Option<PathBuf>, SourceError),
Diff(Option<PathBuf>, io::Error),
RangeFormatNotebook(Option<PathBuf>),
}

impl FormatCommandError {
Expand All @@ -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(),
}
}
}
Expand All @@ -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)
)
}
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand All @@ -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()
)
}
}
Expand Down
Loading

0 comments on commit 53fb8a4

Please sign in to comment.