Skip to content

Commit b3dc565

Browse files
MichaReiserT-256
andauthored
Add --range option to ruff format (#9733)
Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com>
1 parent e708c08 commit b3dc565

7 files changed

Lines changed: 652 additions & 20 deletions

File tree

crates/ruff/src/args.rs

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
use std::cmp::Ordering;
2+
use std::fmt::Formatter;
13
use std::path::PathBuf;
4+
use std::str::FromStr;
25

36
use clap::{command, Parser};
7+
use colored::Colorize;
48
use regex::Regex;
59
use rustc_hash::FxHashMap;
610

@@ -12,6 +16,8 @@ use ruff_linter::settings::types::{
1216
SerializationFormat, UnsafeFixes,
1317
};
1418
use ruff_linter::{warn_user, RuleParser, RuleSelector, RuleSelectorParser};
19+
use ruff_source_file::{LineIndex, OneIndexed};
20+
use ruff_text_size::TextRange;
1521
use ruff_workspace::configuration::{Configuration, RuleSelection};
1622
use ruff_workspace::options::PycodestyleOptions;
1723
use ruff_workspace::resolver::ConfigurationTransformer;
@@ -440,6 +446,21 @@ pub struct FormatCommand {
440446
preview: bool,
441447
#[clap(long, overrides_with("preview"), hide = true)]
442448
no_preview: bool,
449+
450+
/// When specified, Ruff will try to only format the code in the given range.
451+
/// It might be necessary to extend the start backwards or the end forwards, to fully enclose a logical line.
452+
/// The `<RANGE>` uses the format `<start_line>:<start_column>-<end_line>:<end_column>`.
453+
///
454+
/// - The line and column numbers are 1 based.
455+
/// - The column specifies the nth-unicode codepoint on that line.
456+
/// - The end offset is exclusive.
457+
/// - The column numbers are optional. You can write `--range=1-2` instead of `--range=1:1-2:1`.
458+
/// - The end position is optional. You can write `--range=2` to format the entire document starting from the second line.
459+
/// - The start position is optional. You can write `--range=-3` to format the first three lines of the document.
460+
///
461+
/// The option can only be used when formatting a single file. Range formatting of notebooks is unsupported.
462+
#[clap(long, help_heading = "Editor options", verbatim_doc_comment)]
463+
pub range: Option<FormatRange>,
443464
}
444465

445466
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
@@ -570,6 +591,7 @@ impl FormatCommand {
570591
isolated: self.isolated,
571592
no_cache: self.no_cache,
572593
stdin_filename: self.stdin_filename,
594+
range: self.range,
573595
},
574596
CliOverrides {
575597
line_length: self.line_length,
@@ -670,6 +692,196 @@ pub struct FormatArguments {
670692
pub files: Vec<PathBuf>,
671693
pub isolated: bool,
672694
pub stdin_filename: Option<PathBuf>,
695+
pub range: Option<FormatRange>,
696+
}
697+
698+
/// A text range specified by line and column numbers.
699+
#[derive(Copy, Clone, Debug)]
700+
pub struct FormatRange {
701+
start: LineColumn,
702+
end: LineColumn,
703+
}
704+
705+
impl FormatRange {
706+
/// Converts the line:column range to a byte offset range specific for `source`.
707+
///
708+
/// Returns an empty range if the start range is past the end of `source`.
709+
pub(super) fn to_text_range(self, source: &str, line_index: &LineIndex) -> TextRange {
710+
let start_byte_offset = line_index.offset(self.start.line, self.start.column, source);
711+
let end_byte_offset = line_index.offset(self.end.line, self.end.column, source);
712+
713+
TextRange::new(start_byte_offset, end_byte_offset)
714+
}
715+
}
716+
717+
impl FromStr for FormatRange {
718+
type Err = FormatRangeParseError;
719+
720+
fn from_str(value: &str) -> Result<Self, Self::Err> {
721+
let (start, end) = value.split_once('-').unwrap_or((value, ""));
722+
723+
let start = if start.is_empty() {
724+
LineColumn::default()
725+
} else {
726+
start.parse().map_err(FormatRangeParseError::InvalidStart)?
727+
};
728+
729+
let end = if end.is_empty() {
730+
LineColumn {
731+
line: OneIndexed::MAX,
732+
column: OneIndexed::MAX,
733+
}
734+
} else {
735+
end.parse().map_err(FormatRangeParseError::InvalidEnd)?
736+
};
737+
738+
if start > end {
739+
return Err(FormatRangeParseError::StartGreaterThanEnd(start, end));
740+
}
741+
742+
Ok(FormatRange { start, end })
743+
}
744+
}
745+
746+
#[derive(Clone, Debug)]
747+
pub enum FormatRangeParseError {
748+
InvalidStart(LineColumnParseError),
749+
InvalidEnd(LineColumnParseError),
750+
751+
StartGreaterThanEnd(LineColumn, LineColumn),
752+
}
753+
754+
impl std::fmt::Display for FormatRangeParseError {
755+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
756+
let tip = " tip:".bold().green();
757+
match self {
758+
FormatRangeParseError::StartGreaterThanEnd(start, end) => {
759+
write!(
760+
f,
761+
"the start position '{start_invalid}' is greater than the end position '{end_invalid}'.\n {tip} Try switching start and end: '{end}-{start}'",
762+
start_invalid=start.to_string().bold().yellow(),
763+
end_invalid=end.to_string().bold().yellow(),
764+
start=start.to_string().green().bold(),
765+
end=end.to_string().green().bold()
766+
)
767+
}
768+
FormatRangeParseError::InvalidStart(inner) => inner.write(f, true),
769+
FormatRangeParseError::InvalidEnd(inner) => inner.write(f, false),
770+
}
771+
}
772+
}
773+
774+
impl std::error::Error for FormatRangeParseError {}
775+
776+
#[derive(Copy, Clone, Debug)]
777+
pub struct LineColumn {
778+
pub line: OneIndexed,
779+
pub column: OneIndexed,
780+
}
781+
782+
impl std::fmt::Display for LineColumn {
783+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
784+
write!(f, "{line}:{column}", line = self.line, column = self.column)
785+
}
786+
}
787+
788+
impl Default for LineColumn {
789+
fn default() -> Self {
790+
LineColumn {
791+
line: OneIndexed::MIN,
792+
column: OneIndexed::MIN,
793+
}
794+
}
795+
}
796+
797+
impl PartialOrd for LineColumn {
798+
#[inline]
799+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
800+
Some(self.cmp(other))
801+
}
802+
}
803+
804+
impl Ord for LineColumn {
805+
fn cmp(&self, other: &Self) -> Ordering {
806+
self.line
807+
.cmp(&other.line)
808+
.then(self.column.cmp(&other.column))
809+
}
810+
}
811+
812+
impl PartialEq for LineColumn {
813+
fn eq(&self, other: &Self) -> bool {
814+
self.cmp(other) == Ordering::Equal
815+
}
816+
}
817+
818+
impl Eq for LineColumn {}
819+
820+
impl FromStr for LineColumn {
821+
type Err = LineColumnParseError;
822+
823+
fn from_str(value: &str) -> Result<Self, Self::Err> {
824+
let (line, column) = value.split_once(':').unwrap_or((value, "1"));
825+
826+
let line: usize = line.parse().map_err(LineColumnParseError::LineParseError)?;
827+
let column: usize = column
828+
.parse()
829+
.map_err(LineColumnParseError::ColumnParseError)?;
830+
831+
match (OneIndexed::new(line), OneIndexed::new(column)) {
832+
(Some(line), Some(column)) => Ok(LineColumn { line, column }),
833+
(Some(line), None) => Err(LineColumnParseError::ZeroColumnIndex { line }),
834+
(None, Some(column)) => Err(LineColumnParseError::ZeroLineIndex { column }),
835+
(None, None) => Err(LineColumnParseError::ZeroLineAndColumnIndex),
836+
}
837+
}
838+
}
839+
840+
#[derive(Clone, Debug)]
841+
pub enum LineColumnParseError {
842+
ZeroLineIndex { column: OneIndexed },
843+
ZeroColumnIndex { line: OneIndexed },
844+
ZeroLineAndColumnIndex,
845+
LineParseError(std::num::ParseIntError),
846+
ColumnParseError(std::num::ParseIntError),
847+
}
848+
849+
impl LineColumnParseError {
850+
fn write(&self, f: &mut std::fmt::Formatter, start_range: bool) -> std::fmt::Result {
851+
let tip = "tip:".bold().green();
852+
853+
let range = if start_range { "start" } else { "end" };
854+
855+
match self {
856+
LineColumnParseError::ColumnParseError(inner) => {
857+
write!(f, "the {range}s column is not a valid number ({inner})'\n {tip} The format is 'line:column'.")
858+
}
859+
LineColumnParseError::LineParseError(inner) => {
860+
write!(f, "the {range} line is not a valid number ({inner})\n {tip} The format is 'line:column'.")
861+
}
862+
LineColumnParseError::ZeroColumnIndex { line } => {
863+
write!(
864+
f,
865+
"the {range} column is 0, but it should be 1 or greater.\n {tip} The column numbers start at 1.\n {tip} Try {suggestion} instead.",
866+
suggestion=format!("{line}:1").green().bold()
867+
)
868+
}
869+
LineColumnParseError::ZeroLineIndex { column } => {
870+
write!(
871+
f,
872+
"the {range} line is 0, but it should be 1 or greater.\n {tip} The line numbers start at 1.\n {tip} Try {suggestion} instead.",
873+
suggestion=format!("1:{column}").green().bold()
874+
)
875+
}
876+
LineColumnParseError::ZeroLineAndColumnIndex => {
877+
write!(
878+
f,
879+
"the {range} line and column are both 0, but they should be 1 or greater.\n {tip} The line and column numbers start at 1.\n {tip} Try {suggestion} instead.",
880+
suggestion="1:1".to_string().green().bold()
881+
)
882+
}
883+
}
884+
}
673885
}
674886

675887
/// CLI settings that function as configuration overrides.

crates/ruff/src/cache.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ mod tests {
10501050
&self.settings.formatter,
10511051
PySourceType::Python,
10521052
FormatMode::Write,
1053+
None,
10531054
Some(cache),
10541055
)
10551056
}

0 commit comments

Comments
 (0)