[ty] Fix empty spans following a line terminator and unprintable character spans in diagnostics (#19535)
## Summary This was previously the last commit in #19415, split out to make it easier to review. This applies the fixes fromc9b99e4,5021f32, and2922490cb8to the new rendering code in `ruff_db`. I initially intended only to fix the empty span after a line terminator (as you can see in the branch name), but the two fixes were tied pretty closely together, and my initial fix for the empty spans needed a big change after trying to handle unprintable characters too. I can still split this up if it would help with review. I would just start with the unprintable characters first. The implementation here is essentially copy-pasted from `ruff_linter::message::text.rs`, with the `SourceCode` struct renamed to `EscapedSourceCode` since there's already a `SourceCode` in scope in `render.rs`. It's also updated slightly to account for the multiple annotations for a single snippet. The original implementation used some types from the `line_width` module from `ruff_linter`. I copied over heavily stripped-down versions of these instead of trying to import them. We could inline the remaining code entirely, if we want, but I thought it was nice enough to keep. I also moved over `ceil_char_boundary`, which is unchanged except to make it a free function taking a `&str` instead of a `Locator` method. All of this code could be deleted from `ruff_linter` if we also move over the `grouped` output format, which will be the last user after #19415. ## Test Plan I added new tests in `ruff_linter` that call into the new rendering code to snapshot the diagnostics for the affected cases. These are copies of existing snapshots in Ruff, so it's helpful to compare them. These are a bit noisy because of the other rendering differences in the header, but all of the `^^^` indicators should be the same. <details><summary>`empty_span_after_line_terminator` diff</summary> ```diff diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E112_E11.py.snap b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__text__tests__empty_span_after_line_terminator.snap index 5ade4346e0..6df75c16f0 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E112_E11.py.snap +++ b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__text__tests__empty_span_after_line_terminator.snap @@ -1,17 +1,20 @@ --- -source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +source: crates/ruff_linter/src/message/text.rs +expression: value.to_string() --- -E11.py:9:1: E112 Expected an indented block +error[no-indented-block]: Expected an indented block + --> E11.py:9:1 | 7 | #: E112 8 | if False: 9 | print() - | ^ E112 + | ^ 10 | #: E113 11 | print() | -E11.py:9:1: SyntaxError: Expected an indented block after `if` statement +error[invalid-syntax]: SyntaxError: Expected an indented block after `if` statement + --> E11.py:9:1 | 7 | #: E112 8 | if False: @@ -21,7 +24,8 @@ E11.py:9:1: SyntaxError: Expected an indented block after `if` statement 11 | print() | -E11.py:12:1: SyntaxError: Unexpected indentation +error[invalid-syntax]: SyntaxError: Unexpected indentation + --> E11.py:12:1 | 10 | #: E113 11 | print() @@ -31,7 +35,8 @@ E11.py:12:1: SyntaxError: Unexpected indentation 14 | mimetype = 'application/x-directory' | -E11.py:14:1: SyntaxError: Expected a statement +error[invalid-syntax]: SyntaxError: Expected a statement + --> E11.py:14:1 | 12 | print() 13 | #: E114 E116 @@ -41,17 +46,19 @@ E11.py:14:1: SyntaxError: Expected a statement 16 | create_date = False | -E11.py:45:1: E112 Expected an indented block +error[no-indented-block]: Expected an indented block + --> E11.py:45:1 | 43 | #: E112 44 | if False: # 45 | print() - | ^ E112 + | ^ 46 | #: 47 | if False: | -E11.py:45:1: SyntaxError: Expected an indented block after `if` statement +error[invalid-syntax]: SyntaxError: Expected an indented block after `if` statement + --> E11.py:45:1 | 43 | #: E112 44 | if False: # ``` </details> <details><summary>`unprintable_characters` diff</summary> ```diff diff --git a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2512_invalid_characters.py.snap b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__text__tests__unprintable_characters.snap index 52cfdf9cce..fcfa1ac9f1 100644 --- a/crates/ruff_linter/src/rules/pylint/snapshots/ruff_linter__rules__pylint__tests__PLE2512_invalid_characters.py.snap +++ b/crates/ruff_linter/src/message/snapshots/ruff_linter__message__text__tests__unprintable_characters.snap @@ -1,161 +1,115 @@ --- -source: crates/ruff_linter/src/rules/pylint/mod.rs +source: crates/ruff_linter/src/message/text.rs +expression: value.to_string() --- -invalid_characters.py:24:12: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:24:12 | 22 | cr_ok = f'\\r' 23 | 24 | sub = 'sub ' - | ^ PLE2512 + | ^ 25 | sub = f'sub ' | - = help: Replace with escape sequence +help: Replace with escape sequence -ℹ Safe fix -21 21 | cr_ok = '\\r' -22 22 | cr_ok = f'\\r' -23 23 | -24 |-sub = 'sub ' - 24 |+sub = 'sub \x1A' -25 25 | sub = f'sub ' -26 26 | -27 27 | sub_ok = '\x1a' - -invalid_characters.py:25:13: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:25:13 | 24 | sub = 'sub ' 25 | sub = f'sub ' - | ^ PLE2512 + | ^ 26 | 27 | sub_ok = '\x1a' | - = help: Replace with escape sequence - -ℹ Safe fix -22 22 | cr_ok = f'\\r' -23 23 | -24 24 | sub = 'sub ' -25 |-sub = f'sub ' - 25 |+sub = f'sub \x1A' -26 26 | -27 27 | sub_ok = '\x1a' -28 28 | sub_ok = f'\x1a' +help: Replace with escape sequence -invalid_characters.py:55:25: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:55:25 | 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ " 54 | 55 | nested_fstrings = f'␈{f'{f'␛'}'}' - | ^ PLE2512 + | ^ 56 | 57 | # https://github.com/astral-sh/ruff/issues/7455#issuecomment-1741998106 | - = help: Replace with escape sequence - -ℹ Safe fix -52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " -53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ " -54 54 | -55 |-nested_fstrings = f'␈{f'{f'␛'}'}' - 55 |+nested_fstrings = f'␈{f'\x1A{f'␛'}'}' -56 56 | -57 57 | # https://github.com/astral-sh/ruff/issues/7455#issuecomment-1741998106 -58 58 | x = f"""}}ab""" +help: Replace with escape sequence -invalid_characters.py:58:12: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:58:12 | 57 | # https://github.com/astral-sh/ruff/issues/7455#issuecomment-1741998106 58 | x = f"""}}ab""" - | ^ PLE2512 + | ^ 59 | # https://github.com/astral-sh/ruff/issues/7455#issuecomment-1741998256 60 | x = f"""}}a␛b""" | - = help: Replace with escape sequence +help: Replace with escape sequence -ℹ Safe fix -55 55 | nested_fstrings = f'␈{f'{f'␛'}'}' -56 56 | -57 57 | # https://github.com/astral-sh/ruff/issues/7455#issuecomment-1741998106 -58 |-x = f"""}}ab""" - 58 |+x = f"""}}a\x1Ab""" -59 59 | # https://github.com/astral-sh/ruff/issues/7455#issuecomment-1741998256 -60 60 | x = f"""}}a␛b""" -61 61 | - -invalid_characters.py:64:12: PLE2512 Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:64:12 | 63 | # https://github.com/astral-sh/ruff/issues/13294 64 | print(r"""␈␛� - | ^ PLE2512 + | ^ 65 | """) 66 | print(fr"""␈␛� | - = help: Replace with escape sequence +help: Replace with escape sequence -invalid_characters.py:66:13: PLE2512 Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:66:13 | 64 | print(r"""␈␛� 65 | """) 66 | print(fr"""␈␛� - | ^ PLE2512 + | ^ 67 | """) 68 | print(Rf"""␈␛� | - = help: Replace with escape sequence +help: Replace with escape sequence -invalid_characters.py:68:13: PLE2512 Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:68:13 | 66 | print(fr"""␈␛� 67 | """) 68 | print(Rf"""␈␛� - | ^ PLE2512 + | ^ 69 | """) | - = help: Replace with escape sequence +help: Replace with escape sequence -invalid_characters.py:73:9: PLE2512 Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:73:9 | 71 | # https://github.com/astral-sh/ruff/issues/18815 72 | b = "\␈" 73 | sub = "\" - | ^ PLE2512 + | ^ 74 | esc = "\␛" 75 | zwsp = "\" | - = help: Replace with escape sequence +help: Replace with escape sequence -invalid_characters.py:80:25: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:80:25 | 78 | # tstrings 79 | esc = t'esc esc ␛' 80 | nested_tstrings = t'␈{t'{t'␛'}'}' - | ^ PLE2512 + | ^ 81 | nested_ftstrings = t'␈{f'{t'␛'}'}' | - = help: Replace with escape sequence - -ℹ Safe fix -77 77 | -78 78 | # tstrings -79 79 | esc = t'esc esc ␛' -80 |-nested_tstrings = t'␈{t'{t'␛'}'}' - 80 |+nested_tstrings = t'␈{t'\x1A{t'␛'}'}' -81 81 | nested_ftstrings = t'␈{f'{t'␛'}'}' -82 82 | +help: Replace with escape sequence -invalid_characters.py:81:26: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead +error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead + --> invalid_characters.py:81:26 | 79 | esc = t'esc esc ␛' 80 | nested_tstrings = t'␈{t'{t'␛'}'}' 81 | nested_ftstrings = t'␈{f'{t'␛'}'}' - | ^ PLE2512 + | ^ | - = help: Replace with escape sequence - -ℹ Safe fix -78 78 | # tstrings -79 79 | esc = t'esc esc ␛' -80 80 | nested_tstrings = t'␈{t'{t'␛'}'}' -81 |-nested_ftstrings = t'␈{f'{t'␛'}'}' - 81 |+nested_ftstrings = t'␈{f'\x1A{t'␛'}'}' -82 82 | +help: Replace with escape sequence ``` </details>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2891,6 +2891,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ty_static",
|
||||
"unicode-width 0.2.1",
|
||||
"web-time",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -42,6 +42,7 @@ serde_json = { workspace = true, optional = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
unicode-width = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch="wasm32")'.dependencies]
|
||||
|
||||
@@ -6,7 +6,9 @@ use ruff_source_file::{LineColumn, SourceCode, SourceFile};
|
||||
use ruff_annotate_snippets::Level as AnnotateLevel;
|
||||
use ruff_text_size::{Ranged, TextRange, TextSize};
|
||||
|
||||
pub use self::render::{DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input};
|
||||
pub use self::render::{
|
||||
DisplayDiagnostic, DisplayDiagnostics, FileResolver, Input, ceil_char_boundary,
|
||||
};
|
||||
use crate::{Db, files::File};
|
||||
|
||||
mod render;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -7,7 +8,7 @@ use ruff_annotate_snippets::{
|
||||
};
|
||||
use ruff_notebook::{Notebook, NotebookIndex};
|
||||
use ruff_source_file::{LineIndex, OneIndexed, SourceCode};
|
||||
use ruff_text_size::{TextRange, TextSize};
|
||||
use ruff_text_size::{TextLen, TextRange, TextSize};
|
||||
|
||||
use crate::diagnostic::stylesheet::DiagnosticStylesheet;
|
||||
use crate::{
|
||||
@@ -520,7 +521,7 @@ impl<'r> RenderableSnippets<'r> {
|
||||
#[derive(Debug)]
|
||||
struct RenderableSnippet<'r> {
|
||||
/// The actual snippet text.
|
||||
snippet: &'r str,
|
||||
snippet: Cow<'r, str>,
|
||||
/// The absolute line number corresponding to where this
|
||||
/// snippet begins.
|
||||
line_start: OneIndexed,
|
||||
@@ -580,6 +581,13 @@ impl<'r> RenderableSnippet<'r> {
|
||||
.iter()
|
||||
.map(|ann| RenderableAnnotation::new(snippet_start, ann))
|
||||
.collect();
|
||||
|
||||
let EscapedSourceCode {
|
||||
text: snippet,
|
||||
annotations,
|
||||
} = replace_whitespace_and_unprintable(snippet, annotations)
|
||||
.fix_up_empty_spans_after_line_terminator();
|
||||
|
||||
RenderableSnippet {
|
||||
snippet,
|
||||
line_start,
|
||||
@@ -590,7 +598,7 @@ impl<'r> RenderableSnippet<'r> {
|
||||
|
||||
/// Convert this to an "annotate" snippet.
|
||||
fn to_annotate<'a>(&'a self, path: &'a str) -> AnnotateSnippet<'a> {
|
||||
AnnotateSnippet::source(self.snippet)
|
||||
AnnotateSnippet::source(&self.snippet)
|
||||
.origin(path)
|
||||
.line_start(self.line_start.get())
|
||||
.annotations(
|
||||
@@ -820,6 +828,230 @@ fn relativize_path<'p>(cwd: &SystemPath, path: &'p str) -> &'p str {
|
||||
path
|
||||
}
|
||||
|
||||
/// Given some source code and annotation ranges, this routine replaces tabs
|
||||
/// with ASCII whitespace, and unprintable characters with printable
|
||||
/// representations of them.
|
||||
///
|
||||
/// The source code and annotations returned are updated to reflect changes made
|
||||
/// to the source code (if any).
|
||||
fn replace_whitespace_and_unprintable<'r>(
|
||||
source: &'r str,
|
||||
mut annotations: Vec<RenderableAnnotation<'r>>,
|
||||
) -> EscapedSourceCode<'r> {
|
||||
// Updates the annotation ranges given by the caller whenever a single byte (at `index` in
|
||||
// `source`) is replaced with `len` bytes.
|
||||
//
|
||||
// When the index occurs before the start of the range, the range is
|
||||
// offset by `len`. When the range occurs after or at the start but before
|
||||
// the end, then the end of the range only is offset by `len`.
|
||||
let mut update_ranges = |index: usize, len: u32| {
|
||||
for ann in &mut annotations {
|
||||
if index < usize::from(ann.range.start()) {
|
||||
ann.range += TextSize::new(len - 1);
|
||||
} else if index < usize::from(ann.range.end()) {
|
||||
ann.range = ann.range.add_end(TextSize::new(len - 1));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If `c` is an unprintable character, then this returns a printable
|
||||
// representation of it (using a fancier Unicode codepoint).
|
||||
let unprintable_replacement = |c: char| -> Option<char> {
|
||||
match c {
|
||||
'\x07' => Some('␇'),
|
||||
'\x08' => Some('␈'),
|
||||
'\x1b' => Some('␛'),
|
||||
'\x7f' => Some('␡'),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
const TAB_SIZE: usize = 4;
|
||||
let mut width = 0;
|
||||
let mut column = 0;
|
||||
let mut last_end = 0;
|
||||
let mut result = String::new();
|
||||
for (index, c) in source.char_indices() {
|
||||
let old_width = width;
|
||||
match c {
|
||||
'\n' | '\r' => {
|
||||
width = 0;
|
||||
column = 0;
|
||||
}
|
||||
'\t' => {
|
||||
let tab_offset = TAB_SIZE - (column % TAB_SIZE);
|
||||
width += tab_offset;
|
||||
column += tab_offset;
|
||||
|
||||
let tab_width =
|
||||
u32::try_from(width - old_width).expect("small width because of tab size");
|
||||
result.push_str(&source[last_end..index]);
|
||||
|
||||
update_ranges(result.text_len().to_usize(), tab_width);
|
||||
|
||||
for _ in 0..tab_width {
|
||||
result.push(' ');
|
||||
}
|
||||
last_end = index + 1;
|
||||
}
|
||||
_ => {
|
||||
width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
|
||||
column += 1;
|
||||
|
||||
if let Some(printable) = unprintable_replacement(c) {
|
||||
result.push_str(&source[last_end..index]);
|
||||
|
||||
let len = printable.text_len().to_u32();
|
||||
update_ranges(result.text_len().to_usize(), len);
|
||||
|
||||
result.push(printable);
|
||||
last_end = index + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No tabs or unprintable chars
|
||||
if result.is_empty() {
|
||||
EscapedSourceCode {
|
||||
annotations,
|
||||
text: Cow::Borrowed(source),
|
||||
}
|
||||
} else {
|
||||
result.push_str(&source[last_end..]);
|
||||
EscapedSourceCode {
|
||||
annotations,
|
||||
text: Cow::Owned(result),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EscapedSourceCode<'r> {
|
||||
text: Cow<'r, str>,
|
||||
annotations: Vec<RenderableAnnotation<'r>>,
|
||||
}
|
||||
|
||||
impl<'r> EscapedSourceCode<'r> {
|
||||
// This attempts to "fix up" the spans on each annotation in the case where
|
||||
// it's an empty span immediately following a line terminator.
|
||||
//
|
||||
// At present, `annotate-snippets` (both upstream and our vendored copy)
|
||||
// will render annotations of such spans to point to the space immediately
|
||||
// following the previous line. But ideally, this should point to the space
|
||||
// immediately preceding the next line.
|
||||
//
|
||||
// After attempting to fix `annotate-snippets` and giving up after a couple
|
||||
// hours, this routine takes a different tact: it adjusts the span to be
|
||||
// non-empty and it will cover the first codepoint of the following line.
|
||||
// This forces `annotate-snippets` to point to the right place.
|
||||
//
|
||||
// See also: <https://github.com/astral-sh/ruff/issues/15509> and
|
||||
// `ruff_linter::message::text::SourceCode::fix_up_empty_spans_after_line_terminator`,
|
||||
// from which this was adapted.
|
||||
fn fix_up_empty_spans_after_line_terminator(mut self) -> EscapedSourceCode<'r> {
|
||||
for ann in &mut self.annotations {
|
||||
let range = ann.range;
|
||||
if !range.is_empty()
|
||||
|| range.start() == TextSize::from(0)
|
||||
|| range.start() >= self.text.text_len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !matches!(
|
||||
self.text.as_bytes()[range.start().to_usize() - 1],
|
||||
b'\n' | b'\r'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let start = range.start();
|
||||
let end = ceil_char_boundary(&self.text, start + TextSize::from(1));
|
||||
ann.range = TextRange::new(start, end);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the closest [`TextSize`] not less than the offset given for which
|
||||
/// `is_char_boundary` is `true`. Unless the offset given is greater than
|
||||
/// the length of the underlying contents, in which case, the length of the
|
||||
/// contents is returned.
|
||||
///
|
||||
/// Can be replaced with `str::ceil_char_boundary` once it's stable.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// From `std`:
|
||||
///
|
||||
/// ```
|
||||
/// use ruff_db::diagnostic::ceil_char_boundary;
|
||||
/// use ruff_text_size::{Ranged, TextLen, TextSize};
|
||||
///
|
||||
/// let source = "❤️🧡💛💚💙💜";
|
||||
/// assert_eq!(source.text_len(), TextSize::from(26));
|
||||
/// assert!(!source.is_char_boundary(13));
|
||||
///
|
||||
/// let closest = ceil_char_boundary(source, TextSize::from(13));
|
||||
/// assert_eq!(closest, TextSize::from(14));
|
||||
/// assert_eq!(&source[..closest.to_usize()], "❤️🧡💛");
|
||||
/// ```
|
||||
///
|
||||
/// Additional examples:
|
||||
///
|
||||
/// ```
|
||||
/// use ruff_db::diagnostic::ceil_char_boundary;
|
||||
/// use ruff_text_size::{Ranged, TextRange, TextSize};
|
||||
///
|
||||
/// let source = "Hello";
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(0)),
|
||||
/// TextSize::from(0)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(5)),
|
||||
/// TextSize::from(5)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(6)),
|
||||
/// TextSize::from(5)
|
||||
/// );
|
||||
///
|
||||
/// let source = "α";
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(0)),
|
||||
/// TextSize::from(0)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(1)),
|
||||
/// TextSize::from(2)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(2)),
|
||||
/// TextSize::from(2)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// ceil_char_boundary(source, TextSize::from(3)),
|
||||
/// TextSize::from(2)
|
||||
/// );
|
||||
/// ```
|
||||
pub fn ceil_char_boundary(text: &str, offset: TextSize) -> TextSize {
|
||||
let upper_bound = offset
|
||||
.to_u32()
|
||||
.saturating_add(4)
|
||||
.min(text.text_len().to_u32());
|
||||
(offset.to_u32()..upper_bound)
|
||||
.map(TextSize::from)
|
||||
.find(|offset| text.is_char_boundary(offset.to_usize()))
|
||||
.unwrap_or_else(|| TextSize::from(upper_bound))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -2359,7 +2591,7 @@ watermelon
|
||||
}
|
||||
|
||||
/// Returns a builder for tersely constructing diagnostics.
|
||||
fn builder(
|
||||
pub(super) fn builder(
|
||||
&mut self,
|
||||
identifier: &'static str,
|
||||
severity: Severity,
|
||||
@@ -2426,7 +2658,7 @@ watermelon
|
||||
///
|
||||
/// See the docs on `TestEnvironment::span` for the meaning of
|
||||
/// `path`, `line_offset_start` and `line_offset_end`.
|
||||
fn primary(
|
||||
pub(super) fn primary(
|
||||
mut self,
|
||||
path: &str,
|
||||
line_offset_start: &str,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::diagnostic::{
|
||||
DiagnosticFormat,
|
||||
render::tests::{create_diagnostics, create_syntax_error_diagnostics},
|
||||
DiagnosticFormat, Severity,
|
||||
render::tests::{TestEnvironment, create_diagnostics, create_syntax_error_diagnostics},
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -63,4 +63,118 @@ mod tests {
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
/// Check that the new `full` rendering code in `ruff_db` handles cases fixed by commit c9b99e4.
|
||||
///
|
||||
/// For example, without the fix, we get diagnostics like this:
|
||||
///
|
||||
/// ```
|
||||
/// error[no-indented-block]: Expected an indented block
|
||||
/// --> example.py:3:1
|
||||
/// |
|
||||
/// 2 | if False:
|
||||
/// | ^
|
||||
/// 3 | print()
|
||||
/// |
|
||||
/// ```
|
||||
///
|
||||
/// where the caret points to the end of the previous line instead of the start of the next.
|
||||
#[test]
|
||||
fn empty_span_after_line_terminator() {
|
||||
let mut env = TestEnvironment::new();
|
||||
env.add(
|
||||
"example.py",
|
||||
r#"
|
||||
if False:
|
||||
print()
|
||||
"#,
|
||||
);
|
||||
env.format(DiagnosticFormat::Full);
|
||||
|
||||
let diagnostic = env
|
||||
.builder(
|
||||
"no-indented-block",
|
||||
Severity::Error,
|
||||
"Expected an indented block",
|
||||
)
|
||||
.primary("example.py", "3:0", "3:0", "")
|
||||
.build();
|
||||
|
||||
insta::assert_snapshot!(env.render(&diagnostic), @r"
|
||||
error[no-indented-block]: Expected an indented block
|
||||
--> example.py:3:1
|
||||
|
|
||||
2 | if False:
|
||||
3 | print()
|
||||
| ^
|
||||
|
|
||||
");
|
||||
}
|
||||
|
||||
/// Check that the new `full` rendering code in `ruff_db` handles cases fixed by commit 2922490.
|
||||
///
|
||||
/// For example, without the fix, we get diagnostics like this:
|
||||
///
|
||||
/// ```
|
||||
/// error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead
|
||||
/// --> example.py:1:25
|
||||
/// |
|
||||
/// 1 | nested_fstrings = f'␈{f'{f'␛'}'}'
|
||||
/// | ^
|
||||
/// |
|
||||
/// ```
|
||||
///
|
||||
/// where the caret points to the `f` in the f-string instead of the start of the invalid
|
||||
/// character (`^Z`).
|
||||
#[test]
|
||||
fn unprintable_characters() {
|
||||
let mut env = TestEnvironment::new();
|
||||
env.add("example.py", "nested_fstrings = f'{f'{f''}'}'");
|
||||
env.format(DiagnosticFormat::Full);
|
||||
|
||||
let diagnostic = env
|
||||
.builder(
|
||||
"invalid-character-sub",
|
||||
Severity::Error,
|
||||
r#"Invalid unescaped character SUB, use "\x1A" instead"#,
|
||||
)
|
||||
.primary("example.py", "1:24", "1:24", "")
|
||||
.build();
|
||||
|
||||
insta::assert_snapshot!(env.render(&diagnostic), @r#"
|
||||
error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead
|
||||
--> example.py:1:25
|
||||
|
|
||||
1 | nested_fstrings = f'␈{f'{f'␛'}'}'
|
||||
| ^
|
||||
|
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_unprintable_characters() -> std::io::Result<()> {
|
||||
let mut env = TestEnvironment::new();
|
||||
env.add("example.py", "");
|
||||
env.format(DiagnosticFormat::Full);
|
||||
|
||||
let diagnostic = env
|
||||
.builder(
|
||||
"invalid-character-sub",
|
||||
Severity::Error,
|
||||
r#"Invalid unescaped character SUB, use "\x1A" instead"#,
|
||||
)
|
||||
.primary("example.py", "1:1", "1:1", "")
|
||||
.build();
|
||||
|
||||
insta::assert_snapshot!(env.render(&diagnostic), @r#"
|
||||
error[invalid-character-sub]: Invalid unescaped character SUB, use "\x1A" instead
|
||||
--> example.py:1:2
|
||||
|
|
||||
1 | ␈␛
|
||||
| ^
|
||||
|
|
||||
"#);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,86 +118,6 @@ impl<'a> Locator<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the closest [`TextSize`] not less than the offset given for which
|
||||
/// `is_char_boundary` is `true`. Unless the offset given is greater than
|
||||
/// the length of the underlying contents, in which case, the length of the
|
||||
/// contents is returned.
|
||||
///
|
||||
/// Can be replaced with `str::ceil_char_boundary` once it's stable.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// From `std`:
|
||||
///
|
||||
/// ```
|
||||
/// use ruff_text_size::{Ranged, TextSize};
|
||||
/// use ruff_linter::Locator;
|
||||
///
|
||||
/// let locator = Locator::new("❤️🧡💛💚💙💜");
|
||||
/// assert_eq!(locator.text_len(), TextSize::from(26));
|
||||
/// assert!(!locator.contents().is_char_boundary(13));
|
||||
///
|
||||
/// let closest = locator.ceil_char_boundary(TextSize::from(13));
|
||||
/// assert_eq!(closest, TextSize::from(14));
|
||||
/// assert_eq!(&locator.contents()[..closest.to_usize()], "❤️🧡💛");
|
||||
/// ```
|
||||
///
|
||||
/// Additional examples:
|
||||
///
|
||||
/// ```
|
||||
/// use ruff_text_size::{Ranged, TextRange, TextSize};
|
||||
/// use ruff_linter::Locator;
|
||||
///
|
||||
/// let locator = Locator::new("Hello");
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(0)),
|
||||
/// TextSize::from(0)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(5)),
|
||||
/// TextSize::from(5)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(6)),
|
||||
/// TextSize::from(5)
|
||||
/// );
|
||||
///
|
||||
/// let locator = Locator::new("α");
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(0)),
|
||||
/// TextSize::from(0)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(1)),
|
||||
/// TextSize::from(2)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(2)),
|
||||
/// TextSize::from(2)
|
||||
/// );
|
||||
///
|
||||
/// assert_eq!(
|
||||
/// locator.ceil_char_boundary(TextSize::from(3)),
|
||||
/// TextSize::from(2)
|
||||
/// );
|
||||
/// ```
|
||||
pub fn ceil_char_boundary(&self, offset: TextSize) -> TextSize {
|
||||
let upper_bound = offset
|
||||
.to_u32()
|
||||
.saturating_add(4)
|
||||
.min(self.text_len().to_u32());
|
||||
(offset.to_u32()..upper_bound)
|
||||
.map(TextSize::from)
|
||||
.find(|offset| self.contents.is_char_boundary(offset.to_usize()))
|
||||
.unwrap_or_else(|| TextSize::from(upper_bound))
|
||||
}
|
||||
|
||||
/// Take the source code between the given [`TextRange`].
|
||||
#[inline]
|
||||
pub fn slice<T: Ranged>(&self, ranged: T) -> &'a str {
|
||||
|
||||
@@ -6,12 +6,13 @@ use bitflags::bitflags;
|
||||
use colored::Colorize;
|
||||
use ruff_annotate_snippets::{Level, Renderer, Snippet};
|
||||
|
||||
use ruff_db::diagnostic::{Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, SecondaryCode};
|
||||
use ruff_db::diagnostic::{
|
||||
Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, SecondaryCode, ceil_char_boundary,
|
||||
};
|
||||
use ruff_notebook::NotebookIndex;
|
||||
use ruff_source_file::OneIndexed;
|
||||
use ruff_text_size::{TextLen, TextRange, TextSize};
|
||||
|
||||
use crate::Locator;
|
||||
use crate::line_width::{IndentWidth, LineWidthBuilder};
|
||||
use crate::message::diff::Diff;
|
||||
use crate::message::{Emitter, EmitterContext};
|
||||
@@ -376,9 +377,8 @@ impl<'a> SourceCode<'a> {
|
||||
if self.text.as_bytes()[self.annotation_range.start().to_usize() - 1] != b'\n' {
|
||||
return self;
|
||||
}
|
||||
let locator = Locator::new(&self.text);
|
||||
let start = self.annotation_range.start();
|
||||
let end = locator.ceil_char_boundary(start + TextSize::from(1));
|
||||
let end = ceil_char_boundary(&self.text, start + TextSize::from(1));
|
||||
SourceCode {
|
||||
annotation_range: TextRange::new(start, end),
|
||||
..self
|
||||
|
||||
Reference in New Issue
Block a user