ruff_linter 0.16.4

This is an internal component crate of Ruff
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
#![cfg(any(test, fuzzing, feature = "testing"))]
//! Helper functions for the tests of rule implementations.

use std::borrow::Cow;
use std::path::Path;

#[cfg(test)]
use anyhow::Result;
use itertools::Itertools;
use rustc_hash::FxHashMap;

use ruff_db::diagnostic::{
    Diagnostic, DiagnosticFormat, DisplayDiagnosticConfig, DisplayDiagnostics, Span,
};
use ruff_notebook::Notebook;
#[cfg(test)]
use ruff_notebook::NotebookError;
use ruff_python_ast::PySourceType;
use ruff_python_codegen::Stylist;
use ruff_python_index::Indexer;
use ruff_python_parser::ParseError;
use ruff_python_trivia::textwrap::dedent;
use ruff_source_file::SourceFileBuilder;

use crate::codes::Rule;
use crate::fix::{FixResult, fix_file};
use crate::linter::check_path;
use crate::message::EmitterContext;
use crate::package::PackageRoot;
use crate::packaging::detect_package_root;
use crate::settings::types::UnsafeFixes;
use crate::settings::{LinterSettings, flags};
use crate::source_kind::SourceKind;
use crate::suppression::Suppressions;
use crate::{Applicability, FixAvailability};
use crate::{Locator, directives};

/// Represents the difference between two diagnostic runs.
#[cfg(any(test, fuzzing))]
#[derive(Debug)]
pub(crate) struct DiagnosticsDiff {
    /// Diagnostics that were removed (present in 'before' but not in 'after')
    removed: Vec<Diagnostic>,
    /// Diagnostics that were added (present in 'after' but not in 'before')
    added: Vec<Diagnostic>,
    /// Settings used before the change
    settings_before: LinterSettings,
    /// Settings used after the change
    settings_after: LinterSettings,
}

#[cfg(any(test, fuzzing))]
impl std::fmt::Display for DiagnosticsDiff {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        writeln!(f, "--- Linter settings ---")?;
        let settings_before_str = format!("{}", self.settings_before);
        let settings_after_str = format!("{}", self.settings_after);
        let diff = similar::TextDiff::from_lines(&settings_before_str, &settings_after_str);
        for change in diff.iter_all_changes() {
            match change.tag() {
                similar::ChangeTag::Delete => write!(f, "-{change}")?,
                similar::ChangeTag::Insert => write!(f, "+{change}")?,
                similar::ChangeTag::Equal => (),
            }
        }
        writeln!(f)?;

        writeln!(f, "--- Summary ---")?;
        writeln!(f, "Removed: {}", self.removed.len())?;
        writeln!(f, "Added: {}", self.added.len())?;
        writeln!(f)?;

        if !self.removed.is_empty() {
            writeln!(f, "--- Removed ---")?;
            for diagnostic in &self.removed {
                writeln!(f, "{}", print_messages(std::slice::from_ref(diagnostic)))?;
            }
            writeln!(f)?;
        }

        if !self.added.is_empty() {
            writeln!(f, "--- Added ---")?;
            for diagnostic in &self.added {
                writeln!(f, "{}", print_messages(std::slice::from_ref(diagnostic)))?;
            }
            writeln!(f)?;
        }

        Ok(())
    }
}

/// Compare two sets of diagnostics and return the differences
#[cfg(any(test, fuzzing))]
fn diff_diagnostics(
    before: Vec<Diagnostic>,
    after: Vec<Diagnostic>,
    settings_before: &LinterSettings,
    settings_after: &LinterSettings,
) -> DiagnosticsDiff {
    let mut removed = Vec::new();
    let mut added = after;

    for old_diag in before {
        let Some(pos) = added.iter().position(|diag| diag == &old_diag) else {
            removed.push(old_diag);
            continue;
        };
        added.remove(pos);
    }

    DiagnosticsDiff {
        removed,
        added,
        settings_before: settings_before.clone(),
        settings_after: settings_after.clone(),
    }
}

#[cfg(test)]
pub(crate) fn test_resource_path(path: impl AsRef<Path>) -> std::path::PathBuf {
    Path::new("./resources/test/").join(path)
}

/// Run [`check_path`] on a Python file in the `resources/test/fixtures` directory.
#[cfg(test)]
pub(crate) fn test_path(
    path: impl AsRef<Path>,
    settings: &LinterSettings,
) -> Result<Vec<Diagnostic>> {
    let path = test_resource_path("fixtures").join(path);
    let source_type = ruff_python_ast::SourceType::Python(PySourceType::from(&path));
    let source_kind = SourceKind::from_path(path.as_ref(), source_type)?.expect("valid source");
    Ok(test_contents(&source_kind, &path, settings).0)
}

/// Run the configuration TOML linter on a file in the `resources/test/fixtures` directory.
#[cfg(test)]
pub(crate) fn test_toml_path(
    path: impl AsRef<Path>,
    settings: &LinterSettings,
    source_type: ruff_python_ast::TomlSourceType,
) -> Result<Vec<Diagnostic>> {
    let path = test_resource_path("fixtures").join(path);
    let filename = path.file_name().unwrap_or_else(|| path.as_os_str());
    let contents = std::fs::read_to_string(&path)?;
    Ok(crate::toml::lint_toml(
        Path::new(filename),
        &contents,
        settings,
        source_type,
    ))
}

/// Test a file with two different settings and return the differences
#[cfg(test)]
pub(crate) fn test_path_with_settings_diff(
    path: impl AsRef<Path>,
    settings_before: &LinterSettings,
    settings_after: &LinterSettings,
) -> Result<DiagnosticsDiff> {
    assert!(
        format!("{settings_before}") != format!("{settings_after}"),
        "Settings must be different for differential testing"
    );

    let diagnostics_before = test_path(&path, settings_before)?;
    let diagnostic_after = test_path(&path, settings_after)?;

    let diff = diff_diagnostics(
        diagnostics_before,
        diagnostic_after,
        settings_before,
        settings_after,
    );
    Ok(diff)
}

#[cfg(test)]
pub(crate) struct TestedNotebook {
    pub(crate) diagnostics: Vec<Diagnostic>,
    pub(crate) source_notebook: Notebook,
    pub(crate) linted_notebook: Notebook,
}

#[cfg(test)]
pub(crate) fn assert_notebook_path(
    path: impl AsRef<Path>,
    expected: impl AsRef<Path>,
    settings: &LinterSettings,
) -> Result<TestedNotebook, NotebookError> {
    let source_notebook = Notebook::from_path(path.as_ref())?;

    let source_kind = SourceKind::ipy_notebook(source_notebook);
    let (messages, transformed) = test_contents(&source_kind, path.as_ref(), settings);
    let expected_notebook = Notebook::from_path(expected.as_ref())?;
    let linted_notebook = transformed.into_owned().expect_ipy_notebook();

    assert_eq!(
        linted_notebook.cell_offsets(),
        expected_notebook.cell_offsets()
    );
    assert_eq!(linted_notebook.index(), expected_notebook.index());
    assert_eq!(
        linted_notebook.source_code(),
        expected_notebook.source_code()
    );

    Ok(TestedNotebook {
        diagnostics: messages,
        source_notebook: source_kind.expect_ipy_notebook(),
        linted_notebook,
    })
}

/// Run [`check_path`] on a snippet of Python code.
pub fn test_snippet(contents: &str, settings: &LinterSettings) -> Vec<Diagnostic> {
    let path = Path::new("<filename>");
    let contents = dedent(contents);
    test_contents(
        &SourceKind::Python {
            code: contents.into_owned(),
            is_stub: false,
        },
        path,
        settings,
    )
    .0
}

thread_local! {
    static MAX_ITERATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(10) };
}

pub fn set_max_iterations(max: usize) {
    MAX_ITERATIONS.with(|iterations| iterations.set(max));
}

pub(crate) fn max_iterations() -> usize {
    MAX_ITERATIONS.with(std::cell::Cell::get)
}

/// A convenient wrapper around [`check_path`], that additionally
/// asserts that fixes converge after a fixed number of iterations.
pub fn test_contents<'a>(
    source_kind: &'a SourceKind,
    path: &Path,
    settings: &LinterSettings,
) -> (Vec<Diagnostic>, Cow<'a, SourceKind>) {
    let source_type = PySourceType::from(path);
    let target_version = settings.resolve_target_version(path);
    let parsed = crate::linter::parse_unchecked_source(
        source_kind,
        source_type,
        target_version.parser_version(),
    );
    let locator = Locator::new(source_kind.source_code());
    let stylist = Stylist::from_tokens(parsed.tokens(), locator.contents());
    let indexer = Indexer::from_tokens(parsed.tokens(), locator.contents());
    let directives = directives::extract_directives(
        parsed.tokens(),
        directives::Flags::from_settings(settings),
        &locator,
        &indexer,
    );
    let suppressions =
        Suppressions::from_tokens(locator.contents(), parsed.tokens(), &indexer, settings);
    let messages = check_path(
        path,
        path.parent()
            .and_then(|parent| detect_package_root(parent, &settings.namespace_packages))
            .map(|path| PackageRoot::Root { path }),
        &locator,
        &stylist,
        &indexer,
        &directives,
        settings,
        flags::Noqa::Enabled,
        source_kind,
        source_type,
        &parsed,
        target_version,
        &suppressions,
    );

    let source_has_errors = parsed.has_invalid_syntax();

    // Detect fixes that don't converge after multiple iterations.
    let mut iterations = 0;

    let mut transformed = Cow::Borrowed(source_kind);

    if messages.iter().any(|message| message.fix().is_some()) {
        let mut messages = messages.clone();

        while let Some(FixResult {
            code: fixed_contents,
            source_map,
            ..
        }) = fix_file(
            &messages,
            &Locator::new(transformed.source_code()),
            UnsafeFixes::Enabled,
        ) {
            if iterations < max_iterations() {
                iterations += 1;
            } else {
                let output = print_diagnostics(messages, path, &transformed);

                panic!(
                    "Failed to converge after {} iterations. This likely \
                     indicates a bug in the implementation of the fix. Last diagnostics:\n{}",
                    max_iterations(),
                    output
                );
            }

            transformed = Cow::Owned(transformed.updated(fixed_contents, &source_map));

            let parsed = crate::linter::parse_unchecked_source(
                &transformed,
                source_type,
                target_version.parser_version(),
            );
            let locator = Locator::new(transformed.source_code());
            let stylist = Stylist::from_tokens(parsed.tokens(), locator.contents());
            let indexer = Indexer::from_tokens(parsed.tokens(), locator.contents());
            let directives = directives::extract_directives(
                parsed.tokens(),
                directives::Flags::from_settings(settings),
                &locator,
                &indexer,
            );

            let suppressions =
                Suppressions::from_tokens(locator.contents(), parsed.tokens(), &indexer, settings);
            let fixed_messages = check_path(
                path,
                None,
                &locator,
                &stylist,
                &indexer,
                &directives,
                settings,
                flags::Noqa::Enabled,
                &transformed,
                source_type,
                &parsed,
                target_version,
                &suppressions,
            );

            if parsed.has_invalid_syntax() && !source_has_errors {
                // Previous fix introduced a syntax error, abort
                let fixes = print_diagnostics(messages, path, source_kind);
                let syntax_errors = print_syntax_errors(parsed.errors(), path, &transformed);

                panic!(
                    "Fixed source has a syntax error where the source document does not. This is a bug in one of the generated fixes:
{syntax_errors}
Last generated fixes:
{fixes}
Source with applied fixes:
{}",
                    transformed.source_code()
                );
            }

            messages = fixed_messages;
        }
    }

    let source_code = SourceFileBuilder::new(
        path.file_name().unwrap().to_string_lossy().as_ref(),
        source_kind.source_code(),
    )
    .finish();

    let messages = messages
        .into_iter()
        .filter_map(|msg| Some((msg.secondary_code()?.to_string(), msg)))
        .map(|(code, mut diagnostic)| {
            let rule = Rule::from_code(&code).unwrap();
            let fixable = diagnostic.fix().is_some_and(|fix| {
                matches!(
                    fix.applicability(),
                    Applicability::Safe | Applicability::Unsafe
                )
            });

            match (fixable, rule.fixable()) {
                (true, FixAvailability::Sometimes | FixAvailability::Always)
                | (false, FixAvailability::None | FixAvailability::Sometimes) => {
                    // Ok
                }
                (true, FixAvailability::None) => {
                    panic!(
                        "Rule {rule:?} is marked as non-fixable but it created a fix.
Change the `Violation::FIX_AVAILABILITY` to either \
`FixAvailability::Sometimes` or `FixAvailability::Always`"
                    );
                }
                (false, FixAvailability::Always) if source_has_errors => {
                    // Ok
                }
                (false, FixAvailability::Always) => {
                    panic!(
                        "\
Rule {rule:?} is marked to always-fixable but the diagnostic has no fix.
Either ensure you always emit a fix or change `Violation::FIX_AVAILABILITY` to either \
`FixAvailability::Sometimes` or `FixAvailability::None`"
                    )
                }
            }

            assert!(
                !(fixable && diagnostic.first_help_text().is_none()),
                "Diagnostic emitted by {rule:?} is fixable but \
                `Violation::fix_title` returns `None`"
            );

            // Not strictly necessary but adds some coverage for this code path by overriding the
            // noqa offset and the source file
            if let Some(range) = diagnostic.range() {
                diagnostic.set_noqa_offset(directives.noqa_line_for.resolve(range.start()));
            }
            // This part actually is necessary to avoid long relative paths in snapshots.
            for annotation in diagnostic.annotations_mut() {
                if let Some(range) = annotation.get_span().range() {
                    annotation.set_span(Span::from(source_code.clone()).with_range(range));
                }
            }
            for sub in diagnostic.sub_diagnostics_mut() {
                for annotation in sub.annotations_mut() {
                    if let Some(range) = annotation.get_span().range() {
                        annotation.set_span(Span::from(source_code.clone()).with_range(range));
                    }
                }
            }

            diagnostic
        })
        .chain(parsed.errors().iter().map(|parse_error| {
            Diagnostic::invalid_syntax(source_code.clone(), &parse_error.error, parse_error)
        }))
        .sorted_by(Diagnostic::ruff_start_ordering)
        .collect();
    (messages, transformed)
}

fn print_syntax_errors(errors: &[ParseError], path: &Path, source: &SourceKind) -> String {
    let filename = path.file_name().unwrap().to_string_lossy();
    let source_file = SourceFileBuilder::new(filename.as_ref(), source.source_code()).finish();

    let messages: Vec<_> = errors
        .iter()
        .map(|parse_error| {
            Diagnostic::invalid_syntax(source_file.clone(), &parse_error.error, parse_error)
        })
        .collect();

    if let Some(notebook) = source.as_ipy_notebook() {
        print_jupyter_messages(&messages, path, notebook)
    } else {
        print_messages(&messages)
    }
}

/// Print the lint diagnostics in `diagnostics`.
fn print_diagnostics(mut diagnostics: Vec<Diagnostic>, path: &Path, source: &SourceKind) -> String {
    diagnostics.retain(|msg| !msg.is_invalid_syntax());

    if let Some(notebook) = source.as_ipy_notebook() {
        print_jupyter_messages(&diagnostics, path, notebook)
    } else {
        print_messages(&diagnostics)
    }
}

pub(crate) fn print_jupyter_messages(
    diagnostics: &[Diagnostic],
    path: &Path,
    notebook: &Notebook,
) -> String {
    let config = DisplayDiagnosticConfig::new("ruff")
        .format(DiagnosticFormat::Full)
        .hide_severity(true)
        .with_show_fix_status(true)
        .with_fix_applicability(Applicability::DisplayOnly);

    DisplayDiagnostics::new(
        &EmitterContext::new(&FxHashMap::from_iter([(
            path.file_name().unwrap().to_string_lossy().to_string(),
            notebook.index().clone(),
        )])),
        &config,
        diagnostics,
    )
    .to_string()
}

pub(crate) fn print_messages(diagnostics: &[Diagnostic]) -> String {
    let config = DisplayDiagnosticConfig::new("ruff")
        .format(DiagnosticFormat::Full)
        .hide_severity(true)
        .with_show_fix_status(true)
        .with_fix_applicability(Applicability::DisplayOnly);

    DisplayDiagnostics::new(
        &EmitterContext::new(&FxHashMap::default()),
        &config,
        diagnostics,
    )
    .to_string()
}

#[macro_export]
macro_rules! assert_diagnostics {
    ($value:expr, $path:expr, $notebook:expr) => {{
        insta::with_settings!({ omit_expression => true }, {
            insta::assert_snapshot!(
                $crate::test::print_jupyter_messages(&$value, &$path, &$notebook)
            );
        });
    }};
    ($value:expr, @$snapshot:literal) => {{
        insta::with_settings!({ omit_expression => true }, {
            insta::assert_snapshot!($crate::test::print_messages(&$value), @$snapshot);
        });
    }};
    ($name:expr, $value:expr) => {{
        insta::with_settings!({ omit_expression => true }, {
            insta::assert_snapshot!($name, $crate::test::print_messages(&$value));
        });
    }};
    ($value:expr) => {{
        insta::with_settings!({ omit_expression => true }, {
            insta::assert_snapshot!($crate::test::print_messages(&$value));
        });
    }};
}

#[macro_export]
macro_rules! assert_diagnostics_diff {
    ($snapshot:expr, $path:expr, $settings_before:expr, $settings_after:expr $(,)?) => {{
        let diff = $crate::test::test_path_with_settings_diff($path, $settings_before, $settings_after)?;
        insta::with_settings!({ omit_expression => true }, {
            insta::assert_snapshot!($snapshot, format!("{}", diff));
        });
    }};
    ($path:expr, $settings_before:expr, $settings_after:expr $(,)?) => {{
        let diff = $crate::test::test_path_with_settings_diff($path, $settings_before, $settings_after)?;
        insta::with_settings!({ omit_expression => true }, {
            insta::assert_snapshot!(format!("{}", diff));
        });
    }};
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_diff_diagnostics() -> Result<()> {
        use crate::codes::Rule;
        use ruff_db::diagnostic::{DiagnosticId, LintName};

        let settings_before = LinterSettings::for_rule(Rule::Print);
        let settings_after = LinterSettings::for_rule(Rule::UnusedImport);

        let test_code = r#"
import sys
import unused_module

def main():
    print(sys.version)
"#;

        let temp_dir = std::env::temp_dir();
        let test_file = temp_dir.join("test_diff.py");
        std::fs::write(&test_file, test_code)?;

        let diff =
            super::test_path_with_settings_diff(&test_file, &settings_before, &settings_after)?;

        assert_eq!(diff.removed.len(), 1, "Should remove 1 print diagnostic");
        assert_eq!(
            diff.removed[0].id(),
            DiagnosticId::Lint(LintName::of("print")),
            "Should remove the print diagnostic"
        );
        assert_eq!(diff.added.len(), 1, "Should add 1 unused import diagnostic");
        assert_eq!(
            diff.added[0].id(),
            DiagnosticId::Lint(LintName::of("unused-import")),
            "Should add the unused import diagnostic"
        );

        std::fs::remove_file(test_file)?;

        Ok(())
    }
}