diff --git a/Cargo.lock b/Cargo.lock index e5828e878..c2cba325c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,36 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" @@ -492,6 +522,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "num-traits", +] + [[package]] name = "clap" version = "4.5.23" @@ -899,6 +938,7 @@ dependencies = [ "cargo-util", "cargo-util-schemas 0.7.0", "cargo_metadata", + "chrono", "dirs", "dunce", "dylint_internal", @@ -916,6 +956,7 @@ dependencies = [ "serde", "serde-untagged", "serde_json", + "syntect", "tempfile", "toml", "url", @@ -954,7 +995,7 @@ version = "3.3.0" dependencies = [ "ansi_term", "anyhow", - "bitflags", + "bitflags 2.6.0", "cargo-util", "cargo_metadata", "ctor", @@ -1127,6 +1168,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "faster-hex" version = "0.9.0" @@ -1253,7 +1304,7 @@ version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" dependencies = [ - "bitflags", + "bitflags 2.6.0", "libc", "libgit2-sys", "log", @@ -1431,7 +1482,7 @@ version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aaeef5d98390a3bcf9dbc6440b520b793d1bf3ed99317dc407b02be995b28e" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "gix-path", "libc", @@ -1587,7 +1638,7 @@ version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74908b4bbc0a0a40852737e5d7889f676f081e340d5451a16e5b4c50d592f111" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "gix-features", "gix-path", @@ -1633,7 +1684,7 @@ version = "0.33.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a9a44eb55bd84bb48f8a44980e951968ced21e171b22d115d1cdcef82a7d73f" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "filetime", "fnv", @@ -1683,7 +1734,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ec879fb6307bb63519ba89be0024c6f61b4b9d61f1a91fd2ce572d89fe9c224" dependencies = [ - "bitflags", + "bitflags 2.6.0", "gix-commitgraph", "gix-date 0.8.7", "gix-hash", @@ -1795,7 +1846,7 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d23bf239532b4414d0e63b8ab3a65481881f7237ed9647bb10c1e3cc54c5ceb" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bstr", "gix-attributes", "gix-config-value", @@ -1919,7 +1970,7 @@ version = "0.10.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8b876ef997a955397809a2ec398d6a45b7a55b4918f2446344330f778d14fd6" dependencies = [ - "bitflags", + "bitflags 2.6.0", "gix-path", "libc", "windows-sys 0.52.0", @@ -1984,7 +2035,7 @@ version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e499a18c511e71cf4a20413b743b9f5bcf64b3d9e81e9c3c6cd399eae55a8840" dependencies = [ - "bitflags", + "bitflags 2.6.0", "gix-commitgraph", "gix-date 0.8.7", "gix-hash", @@ -2507,7 +2558,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.6.0", "libc", "redox_syscall", ] @@ -3017,7 +3068,7 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] @@ -3100,7 +3151,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags", + "bitflags 2.6.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3167,7 +3218,7 @@ version = "0.38.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" dependencies = [ - "bitflags", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -3230,7 +3281,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.6.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3506,6 +3557,26 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax 0.8.5", + "serde", + "serde_derive", + "serde_json", + "thiserror 1.0.69", + "walkdir", +] + [[package]] name = "tar" version = "0.4.43" diff --git a/Cargo.toml b/Cargo.toml index 837b74a1b..fc64b55a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ cargo-platform = "0.1" cargo-util = "0.2" cargo-util-schemas = "0.7" cargo_metadata = "0.19" +chrono = { version = "0.4", default-features = false } clap = "4.5" compiletest_rs = "0.11" ctor = "0.2" @@ -51,6 +52,7 @@ serde = "1.0" serde-untagged = "0.1" serde_json = "1.0" similar-asserts = "1.6" +syntect = { version = "5.2", default-features = false } tempfile = "3.14" thiserror = "2.0" toml = "0.8" diff --git a/cargo-dylint/src/main.rs b/cargo-dylint/src/main.rs index b5264bf2e..0dc4a9809 100644 --- a/cargo-dylint/src/main.rs +++ b/cargo-dylint/src/main.rs @@ -126,6 +126,15 @@ Combine with `--all` to list all lints in all discovered libraries." #[clap(help = "Path to library package")] path: String, + + #[clap( + help_heading = Some("Experimental"), + long, + help = "Try to extract fixes from Clippy repository commits whose date is that of the \ + un-upgraded toolchain or later", + default_value = "false", + )] + auto_correct: bool, }, } @@ -278,9 +287,11 @@ impl From for dylint::opts::Dylint { allow_downgrade, rust_version, path, + auto_correct, }) => dylint::opts::Operation::Upgrade(dylint::opts::Upgrade { allow_downgrade, rust_version, + auto_correct, path, }), }; diff --git a/cargo-dylint/tests/integration/package_options.rs b/cargo-dylint/tests/integration/package_options.rs index c2a6f2f92..5dbcd57ce 100644 --- a/cargo-dylint/tests/integration/package_options.rs +++ b/cargo-dylint/tests/integration/package_options.rs @@ -1,11 +1,15 @@ use anyhow::{anyhow, Context, Result}; use assert_cmd::prelude::*; use cargo_metadata::{Dependency, MetadataCommand}; -use dylint_internal::{rustup::SanitizeEnvironment, CommandExt}; +use dylint_internal::{clone, rustup::SanitizeEnvironment, CommandExt}; use predicates::prelude::*; use regex::Regex; use semver::Version; -use std::{fs::read_to_string, path::Path}; +use std::{ + fs::read_to_string, + io::{stderr, Write}, + path::Path, +}; use tempfile::tempdir; // smoelius: I expected `git2-0.17.2` to build with nightly-2022-06-30, which corresponds to @@ -139,6 +143,61 @@ fn downgrade_upgrade_package() { .unwrap(); } +const DYLINT_URL: &str = "https://github.com/trailofbits/dylint"; + +// smoelius: Each of the following commits is right before an "Upgrade examples" commit. In the +// upgrades, the changes to the `restriction` lints are small. Thus, the auto-correct option should +// be able to generate fixes for them. +const REVS_AND_RUST_VERSIONS: &[(&str, &str)] = &[ + // smoelius: "Upgrade examples" commit: + // https://github.com/trailofbits/dylint/commit/33969746aef6947c68d7adb55137ce8a13d9cc47 + ("5b3792515ac255fdb06a31b10eb3c9f7949a3ed5", "1.80.0"), + // smoelius: "Upgrade examples" commit: + // https://github.com/trailofbits/dylint/commit/7bc453f0778dee3b13bc1063773774304ac96cad + ("23c08c8a0b043d26f66653bf173a0e6722a2d699", "1.79.0"), +]; + +#[test] +fn upgrade_with_auto_correct() { + for (rev, rust_version) in REVS_AND_RUST_VERSIONS { + let tempdir = tempdir().unwrap(); + + clone(DYLINT_URL, rev, tempdir.path(), false).unwrap(); + + let mut command = std::process::Command::cargo_bin("cargo-dylint").unwrap(); + command.args([ + "dylint", + "upgrade", + &tempdir + .path() + .join("examples/restriction") + .to_string_lossy(), + "--auto-correct", + "--rust-version", + rust_version, + ]); + command.success().unwrap(); + + // smoelius: Sanity. + let mut command = std::process::Command::new("git"); + command.current_dir(&tempdir); + command.args(["--no-pager", "diff", "--", "*.rs"]); + command.success().unwrap(); + + dylint_internal::cargo::check("auto-corrected, upgraded library package") + .build() + .sanitize_environment() + .current_dir(tempdir.path().join("examples/restriction")) + .env("RUSTFLAGS", "--allow=warnings") + .arg("--quiet") + .success() + .unwrap(); + + #[allow(clippy::explicit_write)] + writeln!(stderr(), "Success").unwrap(); + } +} + #[allow(dead_code)] fn rust_version(path: &Path) -> Result { let re = Regex::new(r#"^clippy_utils = .*\btag = "rust-([^"]*)""#).unwrap(); diff --git a/dylint/Cargo.toml b/dylint/Cargo.toml index ec0f38d72..21e69f179 100644 --- a/dylint/Cargo.toml +++ b/dylint/Cargo.toml @@ -17,6 +17,7 @@ cargo-platform = { workspace = true, optional = true } cargo-util = { workspace = true, optional = true } cargo-util-schemas = { workspace = true, optional = true } cargo_metadata = { workspace = true } +chrono = { workspace = true, optional = true } dirs = { workspace = true } dunce = { workspace = true, optional = true } fs_extra = { workspace = true, optional = true } @@ -34,6 +35,12 @@ serde-untagged = { workspace = true, optional = true } serde_json = { workspace = true } tempfile = { workspace = true } toml = { workspace = true, optional = true } +# smoelius: `syntect` can tokenize incomplete Rust fragments, e.g., code with unbalanced delimiters. +syntect = { workspace = true, features = [ + "default-syntaxes", + "regex-fancy", + "parsing", +], optional = true } url = { workspace = true, optional = true } walkdir = { workspace = true } @@ -61,11 +68,13 @@ dylint_internal = { version = "=3.3.0", path = "../internal", features = [ default = [] library_packages = ["__cargo_cli"] package_options = [ + "chrono", "dylint_internal/clippy_utils", "dylint_internal/git", "heck", "if_chain", "rewriter", + "syntect", ] __cargo_cli = [ "cargo-util", diff --git a/dylint/src/opts.rs b/dylint/src/opts.rs index 2ed52a4e1..0d34b212f 100644 --- a/dylint/src/opts.rs +++ b/dylint/src/opts.rs @@ -107,6 +107,8 @@ pub struct Upgrade { pub rust_version: Option, + pub auto_correct: bool, + pub path: String, } diff --git a/dylint/src/package_options/auto_correct/highlight.rs b/dylint/src/package_options/auto_correct/highlight.rs new file mode 100644 index 000000000..075634cdf --- /dev/null +++ b/dylint/src/package_options/auto_correct/highlight.rs @@ -0,0 +1,154 @@ +use super::tokenization::{tokenize_fragment, tokenize_lines}; +use crate::opts; +use anyhow::Result; +use cargo_metadata::diagnostic::{Diagnostic, DiagnosticSpan}; +use dylint_internal::{cargo, rustup::SanitizeEnvironment, CommandExt}; +use serde::Deserialize; +use std::{path::Path, time::Instant}; + +#[derive(Debug, Deserialize)] +struct Message { + reason: String, + #[serde(rename = "message")] + diagnostic: Option, +} + +/// Highlighted text from [`DiagnosticSpanLine`]s +#[derive(Eq, Ord, PartialEq, PartialOrd)] +pub struct Highlight { + /// The diagnostic's message + pub message: String, + /// The name of the file that the diagnostic comes from + pub file_name: String, + /// 1-based line in the file + pub line_start: usize, + /// 1-based line in the file + pub line_end: usize, + /// Text from the [`DiagnosticSpanLine`]s + pub lines: Vec, + /// Tokenized [`DiagnosticSpanLine`]s + pub tokens: Vec, + /// Token index where the highlight starts + pub highlight_start: usize, + /// Token index where the highlight ends + pub highlight_end: usize, + /// Whether the source [`DiagnosticSpan`] was "primary" + pub is_primary: bool, +} + +impl std::fmt::Debug for Highlight { + #[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Highlight") + .field("message", &self.message) + .field("file_name", &self.file_name) + .field("line_start", &self.line_start) + .field("line_end", &self.line_end) + .field("lines", &self.lines) + .field("highlight_start", &self.highlight_start) + .field("highlight_end", &self.highlight_end) + .field("is_primary", &self.is_primary) + .finish_non_exhaustive() + } +} + +impl Highlight { + /// Tries to construct a [`Highlight`] from a [`DiagnosticSpan`]. Fails if the highlighted text + /// cannot be tokenized. + fn new(message: &str, span: DiagnosticSpan) -> Result { + let DiagnosticSpan { + file_name, + line_start, + line_end, + column_start, + column_end, + is_primary, + text, + .. + } = span; + + let lines = text + .into_iter() + .map(|span_line| span_line.text) + .collect::>(); + + let lines_borrowed = lines.iter().map(String::as_str).collect::>(); + let tokens_borrowed = tokenize_lines(&lines_borrowed)?; + let tokens = tokens_borrowed + .into_iter() + .map(ToOwned::to_owned) + .collect::>(); + + assert!(!lines.is_empty()); + + // smoelius: The calculations `highlight_start` and `highlight_end` retokenize parts of the + // first and last lines, which is kind of ugly. + let highlight_start = { + let first_line = &lines_borrowed.first().unwrap()[..column_start - 1]; + let first_line_tokens = tokenize_fragment(first_line)?; + first_line_tokens.len() + }; + let highlight_end = { + let last_line = &lines_borrowed.last().unwrap()[column_end - 1..]; + let last_line_tokens = tokenize_fragment(last_line)?; + tokens.len() - last_line_tokens.len() + }; + + Ok(Self { + message: message.to_owned(), + file_name, + line_start, + line_end, + lines, + tokens, + highlight_start, + highlight_end, + is_primary, + }) + } +} + +/// Invokes `cargo build` at `path` and returns the generated diagnostic messages as [`Highlight`]s. +pub fn collect_highlights(opts: &opts::Dylint, path: &Path) -> Result> { + let start = Instant::now(); + let output = cargo::check("upgraded library package") + .quiet(opts.quiet) + .build() + .sanitize_environment() + .current_dir(path) + .arg("--message-format=json") + .logged_output(false)?; + let elapsed = start.elapsed(); + eprintln!( + "Checked upgraded library package in {} seconds", + elapsed.as_secs() + ); + + if output.status.success() { + return Ok(Vec::new()); + } + + let mut highlights = Vec::new(); + let stdout = String::from_utf8(output.stdout)?; + for result in serde_json::Deserializer::from_str(&stdout).into_iter::() { + let message = result?; + if message.reason != "compiler-message" { + continue; + } + let Some(diagnostic) = message.diagnostic else { + continue; + }; + for span in diagnostic.spans { + if span.text.is_empty() { + continue; + } + let highlight = Highlight::new(&diagnostic.message, span)?; + assert!(!highlight.tokens.is_empty()); + highlights.push(highlight); + } + } + + highlights.sort(); + + Ok(highlights) +} diff --git a/dylint/src/package_options/auto_correct/mod.rs b/dylint/src/package_options/auto_correct/mod.rs new file mode 100644 index 000000000..d13316612 --- /dev/null +++ b/dylint/src/package_options/auto_correct/mod.rs @@ -0,0 +1,326 @@ +#![allow(clippy::unwrap_used)] + +use super::{ + common::{self, clippy_repository}, + Backup, +}; +use crate::opts; +use anyhow::{Context, Result}; +use dylint_internal::git2::Oid; +use rewriter::{interface::Span as _, LineColumn, Rewriter, Span}; +use std::{ + collections::{BTreeMap, HashMap}, + fs::{read_to_string, write}, + ops::Range, + path::Path, +}; + +mod tokenization; +use tokenization::tokenize_lines; + +mod highlight; +use highlight::{collect_highlights, Highlight}; + +mod rewrite; +use rewrite::{collect_rewrites, Rewrite}; + +/// Information about the application of a [`Rewrite`] +#[derive(Debug)] +struct ReplacementSource<'rewrite> { + /// Score returned by [`Rewrite::applicability`] + score: usize, + /// Span of the text to be replaced + span: Span, + /// Commit from whence the rewrite came + oid: Oid, + /// The rewrite itself + rewrite: &'rewrite Rewrite, +} + +// smoelius: For each replacement, we retain only the `Rewrite` with the best score. +type ReplacementSourceMap<'rewrite> = BTreeMap>; + +enum Reason<'rewrite> { + None, + Multiple(usize, ReplacementSourceMap<'rewrite>), +} + +pub fn auto_correct( + opts: &opts::Dylint, + upgrade_opts: &opts::Upgrade, + old_channel: &str, + new_oid: Oid, +) -> Result<()> { + let mut backups = BTreeMap::new(); + + auto_correct_revertible(opts, upgrade_opts, old_channel, new_oid, &mut backups)?; + + for (file_name, mut backup) in backups { + backup + .disable() + .with_context(|| format!("Could not disable `{file_name}` backup"))?; + } + + Ok(()) +} + +#[cfg_attr(dylint_lib = "general", allow(non_local_effect_before_error_return))] +#[allow(clippy::module_name_repetitions, clippy::similar_names)] +pub fn auto_correct_revertible( + opts: &opts::Dylint, + upgrade_opts: &opts::Upgrade, + old_channel: &str, + new_oid: Oid, + backups: &mut BTreeMap, +) -> Result<()> { + let path = Path::new(&upgrade_opts.path); + + let mut highlights = collect_highlights(opts, path)?; + + if highlights.is_empty() { + return Ok(()); + } + + let repository = clippy_repository(opts.quiet)?; + + let rewrites = collect_rewrites(old_channel, new_oid, &repository)?; + + loop { + let mut rewriters = BTreeMap::new(); + let mut unrewritable = Vec::new(); + + for highlight in &highlights { + if !rewriters.contains_key(&highlight.file_name) { + if !backups.contains_key(&highlight.file_name) { + let backup = Backup::new(path.join(&highlight.file_name)) + .with_context(|| format!("Could not backup `{}`", highlight.file_name))?; + backups.insert(highlight.file_name.clone(), backup); + } + let contents = + read_to_string(path.join(&highlight.file_name)).with_context(|| { + format!("`read_to_string` failed for `{}`", highlight.file_name) + })?; + // smoelius: Leaking `contents` is a hack. + let rewriter = Rewriter::new(contents.leak()); + rewriters.insert(highlight.file_name.clone(), (0, rewriter)); + } + let (last_rewritten_line, rewriter) = rewriters.get_mut(&highlight.file_name).unwrap(); + // smoelius: A `Rewriter`'s rewrites must be in order. So multiple changes to a line + // must be performed in separate iterations of this loop. A way to circumvent this + // limitation would be to track columns in a `Highlight`. However, that would likely + // complicate scoring `Rewrite`s. + if highlight.line_start <= *last_rewritten_line { + continue; + } + let mut replacement_source_map = applicable_rewrites(&rewrites, highlight)?; + // smoelius: If the next call to `max` succeeds, it means there is at least one + // replacement. + let Some(best_score) = replacement_source_map + .values() + .map(|source| source.score) + .max() + else { + // smoelius: An unrewritable, non-primary highlight is not justification for + // breaking out of the loop. + if highlight.is_primary { + unrewritable.push((highlight, Reason::None)); + } + continue; + }; + replacement_source_map.retain(|_, source| source.score == best_score); + if replacement_source_map.len() > 1 { + if highlight.is_primary { + unrewritable.push(( + highlight, + Reason::Multiple(best_score, replacement_source_map), + )); + } + continue; + } + // smoelius: We know there is at least one replacement, because `max` above was not + // `None`. + let (replacement, source) = replacement_source_map.pop_first().unwrap(); + eprintln!( + "Rewriting {highlight:#?} with score {} rewrite from {}: {}", + best_score, source.oid, source.rewrite + ); + *last_rewritten_line = source.span.end().line; + let _: String = rewriter.rewrite(&source.span, &replacement); + } + + for (file_name, (_, rewriter)) in rewriters { + let contents = rewriter.contents(); + write(path.join(&file_name), contents) + .with_context(|| format!("`write` failed for `{file_name}`"))?; + } + + // smoelius: The existence of unrewritable highlights is not considered an error. For + // example, there could be an associated non-primary highlight that was rewritten and + // resolved the warning. + if !unrewritable.is_empty() { + display_unrewritable(&unrewritable); + return Ok(()); + } + + highlights = collect_highlights(opts, path)?; + + if highlights.is_empty() { + return Ok(()); + } + } +} + +fn applicable_rewrites<'rewrite>( + rewrites: &'rewrite HashMap, + highlight: &Highlight, +) -> Result> { + let mut replacement_source_map = ReplacementSourceMap::new(); + for (rewrite, &oid) in rewrites { + if let Some((score, offset)) = rewrite.applicability(highlight) { + let (_, replacement) = span_and_text_of_tokens( + 1, + &rewrite.new_lines, + rewrite.common_prefix..rewrite.new_tokens.len() - rewrite.common_suffix, + )?; + let (span, _) = span_and_text_of_tokens( + highlight.line_start, + &highlight.lines, + offset + ..offset + + (rewrite.old_tokens.len() + - rewrite.common_suffix + - rewrite.common_prefix), + )?; + if let Some(source) = replacement_source_map.get_mut(&replacement) { + if source.score < score { + *source = ReplacementSource { + score, + span, + oid, + rewrite, + }; + } + } else { + replacement_source_map.insert( + replacement.clone(), + ReplacementSource { + score, + span, + oid, + rewrite, + }, + ); + } + } + } + Ok(replacement_source_map) +} + +/// Returns the span and text of a range of tokens. +/// +/// **Warning** If `range` is empty, the returned span is unspecified. +/// +/// # Arguments +/// +/// - `line_start` is the 1-based line where `lines` start. +/// - `range` is a range of tokens within the tokenization of `lines`. +#[cfg_attr(dylint_lib = "supplementary", allow(commented_code))] +pub fn span_and_text_of_tokens>( + line_start: usize, + lines: &[S], + range: Range, +) -> Result<(Span, String)> { + // smoelius: If a rewrite strictly removes tokens, `range` will be empty. + // assert!(!range.is_empty()); + + if range.is_empty() { + return Ok(Default::default()); + } + + let lines = lines.iter().map(AsRef::as_ref).collect::>(); + + let lines_orig = &lines; + + let tokens = tokenize_lines(&lines)?; + + let mut lines = lines.iter(); + let mut line = lines.next().copied().unwrap(); + + let mut i_token = 0; + let mut start = None; + let mut line_column = LineColumn { + line: line_start, + column: 0, + }; + let mut text = String::new(); + + while i_token < range.end { + if line.as_bytes().iter().all(u8::is_ascii_whitespace) { + if range.start < i_token { + text += line; + text += "\n"; + } + line = lines.next().unwrap(); + line_column.line += 1; + line_column.column = 0; + continue; + } + + let token = tokens[i_token]; + let len = token.len(); + + #[allow(clippy::panic)] + let offset = line + .find(token) + .unwrap_or_else(|| panic!("Could not find token {token:?} in line {line:?}")); + + assert!(line[..offset] + .as_bytes() + .iter() + .all(u8::is_ascii_whitespace)); + if range.start < i_token { + text += &line[..offset]; + } + line = &line[offset..]; + line_column.column += offset; + + if range.start == i_token { + start = Some(line_column); + } + + assert!(line.starts_with(token)); + if range.start <= i_token { + text += token; + } + line = &line[len..]; + line_column.column += len; + + i_token += 1; + } + + #[allow(clippy::panic)] + let start = start.unwrap_or_else(|| { + panic!( + "`start` was not set for {:#?}", + (line_start, lines_orig, line, &text, range), + ) + }); + + Ok((Span::new(start, line_column), text)) +} + +fn display_unrewritable(unrewritable: &[(&Highlight, Reason)]) { + for (highlight, reason) in unrewritable { + assert!(highlight.is_primary); + match reason { + Reason::None => { + eprintln!("Found no applicable rewrites for {highlight:#?}"); + } + Reason::Multiple(score, rewrites) => { + eprintln!( + "Found multiple rewrites with score {score} for {highlight:#?}: {rewrites:#?}" + ); + } + } + } +} diff --git a/dylint/src/package_options/auto_correct/rewrite/diff.rs b/dylint/src/package_options/auto_correct/rewrite/diff.rs new file mode 100644 index 000000000..85dd787e2 --- /dev/null +++ b/dylint/src/package_options/auto_correct/rewrite/diff.rs @@ -0,0 +1,79 @@ +use super::common::parse_as_nightly; +use anyhow::{anyhow, bail, Result}; +use chrono::{LocalResult, TimeZone, Utc}; +use dylint_internal::git2::{Commit, Diff, DiffOptions, Oid, Patch, Repository, Time}; +use std::ffi::OsStr; + +/// Starting with `oid`, works backwards to find all of the commits no earlier than `old_channel`. +pub(super) fn collect_commits<'repo>( + old_channel: &str, + new_oid: Oid, + repository: &'repo Repository, +) -> Result>> { + let earliest = channel_to_time(old_channel)?; + let mut revwalk = repository.revwalk()?; + revwalk.push(new_oid)?; + + let mut commits = Vec::new(); + for result in revwalk { + let oid = result?; + let commit = repository.find_commit(oid)?; + if commit.time() < earliest { + break; + } + commits.push(commit); + } + Ok(commits) +} + +fn channel_to_time(channel: &str) -> Result