From b3dc5654731aa6a152b43a992c020c59bc416ce3 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Mon, 5 Feb 2024 20:21:45 +0100 Subject: [PATCH] Add `--range` option to `ruff format` (#9733) Co-authored-by: T-256 <132141463+T-256@users.noreply.github.com> --- crates/ruff/src/args.rs | 212 ++++++++++++++ crates/ruff/src/cache.rs | 1 + crates/ruff/src/commands/format.rs | 95 +++++-- crates/ruff/src/commands/format_stdin.rs | 7 +- crates/ruff/tests/format.rs | 319 ++++++++++++++++++++++ crates/ruff_source_file/src/line_index.rs | 28 ++ docs/configuration.md | 10 +- 7 files changed, 652 insertions(+), 20 deletions(-) diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index 5742ed4fc176b..26182b037cbe2 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -1,6 +1,10 @@ +use std::cmp::Ordering; +use std::fmt::Formatter; use std::path::PathBuf; +use std::str::FromStr; use clap::{command, Parser}; +use colored::Colorize; use regex::Regex; use rustc_hash::FxHashMap; @@ -12,6 +16,8 @@ use ruff_linter::settings::types::{ SerializationFormat, UnsafeFixes, }; use ruff_linter::{warn_user, RuleParser, RuleSelector, RuleSelectorParser}; +use ruff_source_file::{LineIndex, OneIndexed}; +use ruff_text_size::TextRange; use ruff_workspace::configuration::{Configuration, RuleSelection}; use ruff_workspace::options::PycodestyleOptions; use ruff_workspace::resolver::ConfigurationTransformer; @@ -440,6 +446,21 @@ pub struct FormatCommand { preview: bool, #[clap(long, overrides_with("preview"), hide = true)] no_preview: bool, + + /// When specified, Ruff will try to only format the code in the given range. + /// It might be necessary to extend the start backwards or the end forwards, to fully enclose a logical line. + /// The `` uses the format `:-:`. + /// + /// - The line and column numbers are 1 based. + /// - The column specifies the nth-unicode codepoint on that line. + /// - The end offset is exclusive. + /// - The column numbers are optional. You can write `--range=1-2` instead of `--range=1:1-2:1`. + /// - The end position is optional. You can write `--range=2` to format the entire document starting from the second line. + /// - The start position is optional. You can write `--range=-3` to format the first three lines of the document. + /// + /// The option can only be used when formatting a single file. Range formatting of notebooks is unsupported. + #[clap(long, help_heading = "Editor options", verbatim_doc_comment)] + pub range: Option, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] @@ -570,6 +591,7 @@ impl FormatCommand { isolated: self.isolated, no_cache: self.no_cache, stdin_filename: self.stdin_filename, + range: self.range, }, CliOverrides { line_length: self.line_length, @@ -670,6 +692,196 @@ pub struct FormatArguments { pub files: Vec, pub isolated: bool, pub stdin_filename: Option, + pub range: Option, +} + +/// A text range specified by line and column numbers. +#[derive(Copy, Clone, Debug)] +pub struct FormatRange { + start: LineColumn, + end: LineColumn, +} + +impl FormatRange { + /// Converts the line:column range to a byte offset range specific for `source`. + /// + /// Returns an empty range if the start range is past the end of `source`. + pub(super) fn to_text_range(self, source: &str, line_index: &LineIndex) -> TextRange { + let start_byte_offset = line_index.offset(self.start.line, self.start.column, source); + let end_byte_offset = line_index.offset(self.end.line, self.end.column, source); + + TextRange::new(start_byte_offset, end_byte_offset) + } +} + +impl FromStr for FormatRange { + type Err = FormatRangeParseError; + + fn from_str(value: &str) -> Result { + let (start, end) = value.split_once('-').unwrap_or((value, "")); + + let start = if start.is_empty() { + LineColumn::default() + } else { + start.parse().map_err(FormatRangeParseError::InvalidStart)? + }; + + let end = if end.is_empty() { + LineColumn { + line: OneIndexed::MAX, + column: OneIndexed::MAX, + } + } else { + end.parse().map_err(FormatRangeParseError::InvalidEnd)? + }; + + if start > end { + return Err(FormatRangeParseError::StartGreaterThanEnd(start, end)); + } + + Ok(FormatRange { start, end }) + } +} + +#[derive(Clone, Debug)] +pub enum FormatRangeParseError { + InvalidStart(LineColumnParseError), + InvalidEnd(LineColumnParseError), + + StartGreaterThanEnd(LineColumn, LineColumn), +} + +impl std::fmt::Display for FormatRangeParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let tip = " tip:".bold().green(); + match self { + FormatRangeParseError::StartGreaterThanEnd(start, end) => { + write!( + f, + "the start position '{start_invalid}' is greater than the end position '{end_invalid}'.\n {tip} Try switching start and end: '{end}-{start}'", + start_invalid=start.to_string().bold().yellow(), + end_invalid=end.to_string().bold().yellow(), + start=start.to_string().green().bold(), + end=end.to_string().green().bold() + ) + } + FormatRangeParseError::InvalidStart(inner) => inner.write(f, true), + FormatRangeParseError::InvalidEnd(inner) => inner.write(f, false), + } + } +} + +impl std::error::Error for FormatRangeParseError {} + +#[derive(Copy, Clone, Debug)] +pub struct LineColumn { + pub line: OneIndexed, + pub column: OneIndexed, +} + +impl std::fmt::Display for LineColumn { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{line}:{column}", line = self.line, column = self.column) + } +} + +impl Default for LineColumn { + fn default() -> Self { + LineColumn { + line: OneIndexed::MIN, + column: OneIndexed::MIN, + } + } +} + +impl PartialOrd for LineColumn { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for LineColumn { + fn cmp(&self, other: &Self) -> Ordering { + self.line + .cmp(&other.line) + .then(self.column.cmp(&other.column)) + } +} + +impl PartialEq for LineColumn { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for LineColumn {} + +impl FromStr for LineColumn { + type Err = LineColumnParseError; + + fn from_str(value: &str) -> Result { + let (line, column) = value.split_once(':').unwrap_or((value, "1")); + + let line: usize = line.parse().map_err(LineColumnParseError::LineParseError)?; + let column: usize = column + .parse() + .map_err(LineColumnParseError::ColumnParseError)?; + + match (OneIndexed::new(line), OneIndexed::new(column)) { + (Some(line), Some(column)) => Ok(LineColumn { line, column }), + (Some(line), None) => Err(LineColumnParseError::ZeroColumnIndex { line }), + (None, Some(column)) => Err(LineColumnParseError::ZeroLineIndex { column }), + (None, None) => Err(LineColumnParseError::ZeroLineAndColumnIndex), + } + } +} + +#[derive(Clone, Debug)] +pub enum LineColumnParseError { + ZeroLineIndex { column: OneIndexed }, + ZeroColumnIndex { line: OneIndexed }, + ZeroLineAndColumnIndex, + LineParseError(std::num::ParseIntError), + ColumnParseError(std::num::ParseIntError), +} + +impl LineColumnParseError { + fn write(&self, f: &mut std::fmt::Formatter, start_range: bool) -> std::fmt::Result { + let tip = "tip:".bold().green(); + + let range = if start_range { "start" } else { "end" }; + + match self { + LineColumnParseError::ColumnParseError(inner) => { + write!(f, "the {range}s column is not a valid number ({inner})'\n {tip} The format is 'line:column'.") + } + LineColumnParseError::LineParseError(inner) => { + write!(f, "the {range} line is not a valid number ({inner})\n {tip} The format is 'line:column'.") + } + LineColumnParseError::ZeroColumnIndex { line } => { + write!( + f, + "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.", + suggestion=format!("{line}:1").green().bold() + ) + } + LineColumnParseError::ZeroLineIndex { column } => { + write!( + f, + "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.", + suggestion=format!("1:{column}").green().bold() + ) + } + LineColumnParseError::ZeroLineAndColumnIndex => { + write!( + f, + "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.", + suggestion="1:1".to_string().green().bold() + ) + } + } + } } /// 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..8f719ad07e7bc 100644 --- a/crates/ruff/src/commands/format.rs +++ b/crates/ruff/src/commands/format.rs @@ -23,12 +23,13 @@ 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_source_file::LineIndex; 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::{CliOverrides, FormatArguments, FormatRange}; use crate::cache::{Cache, FileCacheKey, PackageCacheMap, PackageCaches}; use crate::panic::{catch_unwind, PanicError}; use crate::resolve::resolve; @@ -77,6 +78,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 +147,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 +241,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 +266,12 @@ pub(crate) fn format_path( } }; + // Don't write back to the cache if formatting a range. + let 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| { @@ -319,12 +339,31 @@ 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 line_index = LineIndex::from_source_text(unformatted); + let byte_range = range.to_text_range(unformatted, &line_index); + 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 +376,6 @@ pub(crate) fn format_source( } })?; - let formatted = formatted.into_code(); if formatted.len() == unformatted.len() && formatted == *unformatted { Ok(FormattedSource::Unchanged) } else { @@ -349,6 +387,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 +633,7 @@ pub(crate) enum FormatCommandError { Format(Option, FormatModuleError), Write(Option, SourceError), Diff(Option, io::Error), + RangeFormatNotebook(Option), } impl FormatCommandError { @@ -606,7 +651,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 +674,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 +695,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 +708,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 +721,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 +736,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..9f4a05313f571 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::{CliOverrides, FormatArguments, FormatRange}; 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/tests/format.rs b/crates/ruff/tests/format.rs index 491711dee940c..59c2149fc93f3 100644 --- a/crates/ruff/tests/format.rs +++ b/crates/ruff/tests/format.rs @@ -1544,3 +1544,322 @@ 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=2:8-2:14"]) + .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_unicode() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=2:21-3"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1="👋🏽" ): print("Format this" ) +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + def foo(arg1="👋🏽" ): + print("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=1:8-1: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=100:40-200:1"]) + .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=90-50"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Shouldn't format this" ) + +"#), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '90-50' for '--range ': the start position '90:1' is greater than the end position '50:1'. + tip: Try switching start and end: '50:1-90:1' + + For more information, try '--help'. + "###); +} + +#[test] +fn range_line_numbers_only() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=2-3"]) + .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_only() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=3"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Should format this" ) + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + def foo(arg1, arg2,): + print("Should format this") + + ----- stderr ----- + "###); +} + +#[test] +fn range_end_only() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=-3"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Should format this" ) + +"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + def foo( + arg1, + arg2, + ): + print("Should format this" ) + + + ----- stderr ----- + "###); +} + +#[test] +fn range_missing_line() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=1-:20"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Should format this" ) + +"#), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '1-:20' for '--range ': the end line is not a valid number (cannot parse integer from empty string) + tip: The format is 'line:column'. + + For more information, try '--help'. + "###); +} + +#[test] +fn zero_line_number() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=0:2"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Should format this" ) + +"#), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '0:2' for '--range ': the start line is 0, but it should be 1 or greater. + tip: The line numbers start at 1. + tip: Try 1:2 instead. + + For more information, try '--help'. + "###); +} + +#[test] +fn column_and_line_zero() { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(["format", "--isolated", "--stdin-filename", "test.py", "--range=0:0"]) + .arg("-") + .pass_stdin(r#" +def foo(arg1, arg2,): + print("Should format this" ) + +"#), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '0:0' for '--range ': the start line and column are both 0, but they should be 1 or greater. + tip: The line and column numbers start at 1. + tip: Try 1:1 instead. + + For more information, try '--help'. + "###); +} + +#[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=1-2"]) + .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_source_file/src/line_index.rs b/crates/ruff_source_file/src/line_index.rs index 735bf1f7f6291..31db33eb84f8c 100644 --- a/crates/ruff_source_file/src/line_index.rs +++ b/crates/ruff_source_file/src/line_index.rs @@ -215,6 +215,34 @@ impl LineIndex { } } + /// Returns the [byte offset](TextSize) at `line` and `column`. + pub fn offset(&self, line: OneIndexed, column: OneIndexed, contents: &str) -> TextSize { + // If start-of-line position after last line + if line.to_zero_indexed() > self.line_starts().len() { + return contents.text_len(); + } + + let line_range = self.line_range(line, contents); + + match self.kind() { + IndexKind::Ascii => { + line_range.start() + + TextSize::try_from(column.get()) + .unwrap_or(line_range.len()) + .clamp(TextSize::new(0), line_range.len()) + } + IndexKind::Utf8 => { + let rest = &contents[line_range]; + let column_offset: TextSize = rest + .chars() + .take(column.get()) + .map(ruff_text_size::TextLen::text_len) + .sum(); + line_range.start() + column_offset + } + } + } + /// Returns the [byte offsets](TextSize) for every line pub fn line_starts(&self) -> &[TextSize] { &self.inner.line_starts diff --git a/docs/configuration.md b/docs/configuration.md index 715ef73bccf83..acac5fc29d115 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -654,7 +654,7 @@ Options: Enable preview mode; enables unstable formatting. Use `--no-preview` to disable -h, --help - Print help + Print help (see more with '--help') Miscellaneous: -n, --no-cache @@ -679,6 +679,14 @@ File selection: Format configuration: --line-length Set the line-length +Editor options: + --range When specified, Ruff will try to only format the code in + the given range. + It might be necessary to extend the start backwards or + the end forwards, to fully enclose a logical line. + The `` uses the format + `:-:`. + Log levels: -v, --verbose Enable verbose logging -q, --quiet Print diagnostics, but nothing else