diff --git a/Cargo.lock b/Cargo.lock index 02d1a20d7cc87..5d13ef987607c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,21 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "assert_fs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7efdb1fdb47602827a342857666feb372712cbc64b414172bd6b167a02927674" +dependencies = [ + "anstyle", + "doc-comment", + "globwalk", + "predicates", + "predicates-core", + "predicates-tree", + "tempfile", +] + [[package]] name = "autocfg" version = "1.2.0" @@ -240,6 +255,9 @@ name = "camino" version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] [[package]] name = "cast" @@ -722,6 +740,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -773,6 +797,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "drop_bomb" version = "0.1.5" @@ -968,6 +998,17 @@ dependencies = [ "regex-syntax 0.8.3", ] +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.6.0", + "ignore", + "walkdir", +] + [[package]] name = "half" version = "2.4.1" @@ -1864,6 +1905,33 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "predicates" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" + +[[package]] +name = "predicates-tree" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -2191,6 +2259,7 @@ version = "0.6.5" dependencies = [ "anyhow", "argfile", + "assert_fs", "bincode", "bitflags 2.6.0", "cachedir", @@ -2200,7 +2269,9 @@ dependencies = [ "clearscreen", "colored", "filetime", + "globwalk", "ignore", + "indoc", "insta", "insta-cmd", "is-macro", @@ -2212,7 +2283,9 @@ dependencies = [ "rayon", "regex", "ruff_cache", + "ruff_db", "ruff_diagnostics", + "ruff_graph", "ruff_linter", "ruff_macros", "ruff_notebook", @@ -2295,6 +2368,7 @@ dependencies = [ "ruff_text_size", "rustc-hash 2.0.0", "salsa", + "serde", "tempfile", "thiserror", "tracing", @@ -2370,6 +2444,23 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ruff_graph" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "red_knot_python_semantic", + "ruff_cache", + "ruff_db", + "ruff_linter", + "ruff_macros", + "ruff_python_ast", + "salsa", + "schemars", + "serde", +] + [[package]] name = "ruff_index" version = "0.0.0" @@ -2743,6 +2834,7 @@ dependencies = [ "regex", "ruff_cache", "ruff_formatter", + "ruff_graph", "ruff_linter", "ruff_macros", "ruff_python_ast", @@ -3197,6 +3289,12 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "test-case" version = "3.3.1" diff --git a/Cargo.toml b/Cargo.toml index ed43887010589..7455b5b6bd174 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ ruff_cache = { path = "crates/ruff_cache" } ruff_db = { path = "crates/ruff_db" } ruff_diagnostics = { path = "crates/ruff_diagnostics" } ruff_formatter = { path = "crates/ruff_formatter" } +ruff_graph = { path = "crates/ruff_graph" } ruff_index = { path = "crates/ruff_index" } ruff_linter = { path = "crates/ruff_linter" } ruff_macros = { path = "crates/ruff_macros" } @@ -42,6 +43,7 @@ red_knot_workspace = { path = "crates/red_knot_workspace" } aho-corasick = { version = "1.1.3" } annotate-snippets = { version = "0.9.2", features = ["color"] } anyhow = { version = "1.0.80" } +assert_fs = { version = "1.1.0" } argfile = { version = "0.2.0" } bincode = { version = "1.3.3" } bitflags = { version = "2.5.0" } @@ -68,6 +70,7 @@ fern = { version = "0.6.1" } filetime = { version = "0.2.23" } glob = { version = "0.3.1" } globset = { version = "0.4.14" } +globwalk = { version = "0.9.1" } hashbrown = "0.14.3" ignore = { version = "0.4.22" } imara-diff = { version = "0.1.5" } diff --git a/crates/red_knot_python_semantic/src/lib.rs b/crates/red_knot_python_semantic/src/lib.rs index f159bbf9047ff..afdf2da55a6b0 100644 --- a/crates/red_knot_python_semantic/src/lib.rs +++ b/crates/red_knot_python_semantic/src/lib.rs @@ -4,7 +4,9 @@ use rustc_hash::FxHasher; pub use db::Db; pub use module_name::ModuleName; -pub use module_resolver::{resolve_module, system_module_search_paths, vendored_typeshed_stubs}; +pub use module_resolver::{ + resolve_module, system_module_search_paths, vendored_typeshed_stubs, Module, +}; pub use program::{Program, ProgramSettings, SearchPathSettings, SitePackages}; pub use python_version::PythonVersion; pub use semantic_model::{HasTy, SemanticModel}; diff --git a/crates/red_knot_python_semantic/src/module_resolver/mod.rs b/crates/red_knot_python_semantic/src/module_resolver/mod.rs index 31d8d3743d123..a8ba40c09d3c0 100644 --- a/crates/red_knot_python_semantic/src/module_resolver/mod.rs +++ b/crates/red_knot_python_semantic/src/module_resolver/mod.rs @@ -1,6 +1,6 @@ use std::iter::FusedIterator; -pub(crate) use module::Module; +pub use module::Module; pub use resolver::resolve_module; pub(crate) use resolver::{file_to_module, SearchPaths}; use ruff_db::system::SystemPath; diff --git a/crates/ruff/Cargo.toml b/crates/ruff/Cargo.toml index 0e019b5300a3d..5c6583f64e91b 100644 --- a/crates/ruff/Cargo.toml +++ b/crates/ruff/Cargo.toml @@ -14,7 +14,9 @@ default-run = "ruff" [dependencies] ruff_cache = { workspace = true } +ruff_db = { workspace = true } ruff_diagnostics = { workspace = true } +ruff_graph = { workspace = true, features = ["serde", "clap"] } ruff_linter = { workspace = true, features = ["clap"] } ruff_macros = { workspace = true } ruff_notebook = { workspace = true } @@ -36,6 +38,7 @@ clap_complete_command = { workspace = true } clearscreen = { workspace = true } colored = { workspace = true } filetime = { workspace = true } +globwalk = { workspace = true } ignore = { workspace = true } is-macro = { workspace = true } itertools = { workspace = true } @@ -59,8 +62,11 @@ wild = { workspace = true } [dev-dependencies] # Enable test rules during development ruff_linter = { workspace = true, features = ["clap", "test-rules"] } + +assert_fs = { workspace = true } # Avoid writing colored snapshots when running tests from the terminal colored = { workspace = true, features = ["no-color"] } +indoc = { workspace = true } insta = { workspace = true, features = ["filters", "json"] } insta-cmd = { workspace = true } tempfile = { workspace = true } diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index 3862c2a0d9cd5..abd6d1a4f1ab8 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -7,13 +7,11 @@ use std::sync::Arc; use anyhow::{anyhow, bail}; use clap::builder::{TypedValueParser, ValueParserFactory}; -use clap::{command, Parser}; +use clap::{command, Parser, Subcommand}; use colored::Colorize; use path_absolutize::path_dedot; use regex::Regex; -use rustc_hash::FxHashMap; -use toml; - +use ruff_graph::Direction; use ruff_linter::line_width::LineLength; use ruff_linter::logging::LogLevel; use ruff_linter::registry::Rule; @@ -27,6 +25,8 @@ use ruff_text_size::TextRange; use ruff_workspace::configuration::{Configuration, RuleSelection}; use ruff_workspace::options::{Options, PycodestyleOptions}; use ruff_workspace::resolver::ConfigurationTransformer; +use rustc_hash::FxHashMap; +use toml; /// All configuration options that can be passed "globally", /// i.e., can be passed to all subcommands @@ -132,6 +132,9 @@ pub enum Command { Format(FormatCommand), /// Run the language server. Server(ServerCommand), + /// Run analysis over Python source code. + #[clap(subcommand)] + Analyze(AnalyzeCommand), /// Display Ruff's version Version { #[arg(long, value_enum, default_value = "text")] @@ -139,6 +142,32 @@ pub enum Command { }, } +#[derive(Debug, Subcommand)] +pub enum AnalyzeCommand { + /// Generate a map of Python file dependencies or dependents. + Graph(AnalyzeGraphCommand), +} + +#[derive(Clone, Debug, clap::Parser)] +pub struct AnalyzeGraphCommand { + /// List of files or directories to include. + #[clap(help = "List of files or directories to include [default: .]")] + pub files: Vec, + /// The direction of the import map. By default, generates a dependency map, i.e., a map from + /// file to files that it depends on. Use `--direction dependents` to generate a map from file + /// to files that depend on it. + #[clap(long, value_enum, default_value_t)] + pub direction: Direction, + /// Attempt to detect imports from string literals. + #[clap(long)] + pub detect_string_imports: bool, + /// Enable preview mode. Use `--no-preview` to disable. + #[arg(long, overrides_with("no_preview"))] + preview: bool, + #[clap(long, overrides_with("preview"), hide = true)] + no_preview: bool, +} + // The `Parser` derive is for ruff_dev, for ruff `Args` would be sufficient #[derive(Clone, Debug, clap::Parser)] #[allow(clippy::struct_excessive_bools)] @@ -700,6 +729,7 @@ impl CheckCommand { output_format: resolve_output_format(self.output_format)?, show_fixes: resolve_bool_arg(self.show_fixes, self.no_show_fixes), extension: self.extension, + ..ExplicitConfigOverrides::default() }; let config_args = ConfigArguments::from_cli_arguments(global_options, cli_overrides)?; @@ -732,8 +762,33 @@ impl FormatCommand { target_version: self.target_version, cache_dir: self.cache_dir, extension: self.extension, + ..ExplicitConfigOverrides::default() + }; + + let config_args = ConfigArguments::from_cli_arguments(global_options, cli_overrides)?; + Ok((format_arguments, config_args)) + } +} + +impl AnalyzeGraphCommand { + /// Partition the CLI into command-line arguments and configuration + /// overrides. + pub fn partition( + self, + global_options: GlobalConfigArgs, + ) -> anyhow::Result<(AnalyzeGraphArgs, ConfigArguments)> { + let format_arguments = AnalyzeGraphArgs { + files: self.files, + direction: self.direction, + }; - // Unsupported on the formatter CLI, but required on `Overrides`. + let cli_overrides = ExplicitConfigOverrides { + detect_string_imports: if self.detect_string_imports { + Some(true) + } else { + None + }, + preview: resolve_bool_arg(self.preview, self.no_preview).map(PreviewMode::from), ..ExplicitConfigOverrides::default() }; @@ -896,7 +951,7 @@ A `--config` flag must either be a path to a `.toml` configuration file // the user was trying to pass in a path to a configuration file // or some inline TOML. // We want to display the most helpful error to the user as possible. - if std::path::Path::new(value) + if Path::new(value) .extension() .map_or(false, |ext| ext.eq_ignore_ascii_case("toml")) { @@ -1156,6 +1211,13 @@ impl LineColumnParseError { } } +/// CLI settings that are distinct from configuration (commands, lists of files, etc.). +#[derive(Clone, Debug)] +pub struct AnalyzeGraphArgs { + pub files: Vec, + pub direction: Direction, +} + /// Configuration overrides provided via dedicated CLI flags: /// `--line-length`, `--respect-gitignore`, etc. #[derive(Clone, Default)] @@ -1187,6 +1249,7 @@ struct ExplicitConfigOverrides { output_format: Option, show_fixes: Option, extension: Option>, + detect_string_imports: Option, } impl ConfigurationTransformer for ExplicitConfigOverrides { @@ -1271,6 +1334,9 @@ impl ConfigurationTransformer for ExplicitConfigOverrides { if let Some(extension) = &self.extension { config.extension = Some(extension.iter().cloned().collect()); } + if let Some(detect_string_imports) = &self.detect_string_imports { + config.analyze.detect_string_imports = Some(*detect_string_imports); + } config } diff --git a/crates/ruff/src/commands/analyze_graph.rs b/crates/ruff/src/commands/analyze_graph.rs new file mode 100644 index 0000000000000..9fb138553b27f --- /dev/null +++ b/crates/ruff/src/commands/analyze_graph.rs @@ -0,0 +1,182 @@ +use crate::args::{AnalyzeGraphArgs, ConfigArguments}; +use crate::resolve::resolve; +use crate::{resolve_default_files, ExitStatus}; +use anyhow::Result; +use log::{debug, warn}; +use path_absolutize::CWD; +use ruff_db::system::{SystemPath, SystemPathBuf}; +use ruff_graph::{Direction, ImportMap, ModuleDb, ModuleImports}; +use ruff_linter::{warn_user, warn_user_once}; +use ruff_python_ast::{PySourceType, SourceType}; +use ruff_workspace::resolver::{python_files_in_path, ResolvedFile}; +use rustc_hash::FxHashMap; +use std::path::Path; +use std::sync::Arc; + +/// Generate an import map. +pub(crate) fn analyze_graph( + args: AnalyzeGraphArgs, + config_arguments: &ConfigArguments, +) -> Result { + // Construct the "default" settings. These are used when no `pyproject.toml` + // files are present, or files are injected from outside the hierarchy. + let pyproject_config = resolve(config_arguments, None)?; + if pyproject_config.settings.analyze.preview.is_disabled() { + warn_user!("`ruff analyze graph` is experimental and may change without warning"); + } + + // Write all paths relative to the current working directory. + let root = + SystemPathBuf::from_path_buf(CWD.clone()).expect("Expected a UTF-8 working directory"); + + // Find all Python files. + let files = resolve_default_files(args.files, false); + let (paths, resolver) = python_files_in_path(&files, &pyproject_config, config_arguments)?; + + if paths.is_empty() { + warn_user_once!("No Python files found under the given path(s)"); + return Ok(ExitStatus::Success); + } + + // Resolve all package roots. + let package_roots = resolver + .package_roots( + &paths + .iter() + .flatten() + .map(ResolvedFile::path) + .collect::>(), + ) + .into_iter() + .map(|(path, package)| (path.to_path_buf(), package.map(Path::to_path_buf))) + .collect::>(); + + // Create a database for each source root. + let db = ModuleDb::from_src_roots( + package_roots + .values() + .filter_map(|package| package.as_deref()) + .filter_map(|package| package.parent()) + .map(Path::to_path_buf) + .filter_map(|path| SystemPathBuf::from_path_buf(path).ok()), + )?; + + // Collect and resolve the imports for each file. + let result = Arc::new(std::sync::Mutex::new(Vec::new())); + let inner_result = Arc::clone(&result); + + rayon::scope(move |scope| { + for resolved_file in paths { + let Ok(resolved_file) = resolved_file else { + continue; + }; + + let path = resolved_file.into_path(); + let package = path + .parent() + .and_then(|parent| package_roots.get(parent)) + .and_then(Clone::clone); + + // Resolve the per-file settings. + let settings = resolver.resolve(&path); + let string_imports = settings.analyze.detect_string_imports; + let include_dependencies = settings.analyze.include_dependencies.get(&path).cloned(); + + // Ignore non-Python files. + let source_type = match settings.analyze.extension.get(&path) { + None => match SourceType::from(&path) { + SourceType::Python(source_type) => source_type, + SourceType::Toml(_) => { + debug!("Ignoring TOML file: {}", path.display()); + continue; + } + }, + Some(language) => PySourceType::from(language), + }; + if matches!(source_type, PySourceType::Ipynb) { + debug!("Ignoring Jupyter notebook: {}", path.display()); + continue; + } + + // Convert to system paths. + let Ok(package) = package.map(SystemPathBuf::from_path_buf).transpose() else { + warn!("Failed to convert package to system path"); + continue; + }; + let Ok(path) = SystemPathBuf::from_path_buf(path) else { + warn!("Failed to convert path to system path"); + continue; + }; + + let db = db.snapshot(); + let root = root.clone(); + let result = inner_result.clone(); + scope.spawn(move |_| { + // Identify any imports via static analysis. + let mut imports = + ruff_graph::generate(&path, package.as_deref(), string_imports, &db) + .unwrap_or_else(|err| { + warn!("Failed to generate import map for {path}: {err}"); + ModuleImports::default() + }); + + // Append any imports that were statically defined in the configuration. + if let Some((root, globs)) = include_dependencies { + match globwalk::GlobWalkerBuilder::from_patterns(root, &globs) + .file_type(globwalk::FileType::FILE) + .build() + { + Ok(walker) => { + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + warn!("Failed to read glob entry: {err}"); + continue; + } + }; + let path = match SystemPathBuf::from_path_buf(entry.into_path()) { + Ok(path) => path, + Err(err) => { + warn!( + "Failed to convert path to system path: {}", + err.display() + ); + continue; + } + }; + imports.insert(path); + } + } + Err(err) => { + warn!("Failed to read glob walker: {err}"); + } + } + } + + // Convert the path (and imports) to be relative to the working directory. + let path = path + .strip_prefix(&root) + .map(SystemPath::to_path_buf) + .unwrap_or(path); + let imports = imports.relative_to(&root); + + result.lock().unwrap().push((path, imports)); + }); + } + }); + + // Collect the results. + let imports = Arc::into_inner(result).unwrap().into_inner()?; + + // Generate the import map. + let import_map = match args.direction { + Direction::Dependencies => ImportMap::from_iter(imports), + Direction::Dependents => ImportMap::reverse(imports), + }; + + // Print to JSON. + println!("{}", serde_json::to_string_pretty(&import_map)?); + + Ok(ExitStatus::Success) +} diff --git a/crates/ruff/src/commands/mod.rs b/crates/ruff/src/commands/mod.rs index 787a22ed43451..4d463a4ef5d15 100644 --- a/crates/ruff/src/commands/mod.rs +++ b/crates/ruff/src/commands/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod add_noqa; +pub(crate) mod analyze_graph; pub(crate) mod check; pub(crate) mod check_stdin; pub(crate) mod clean; diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index 8ba057cefc2bd..bda58d4a8a833 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -20,7 +20,9 @@ use ruff_linter::settings::types::OutputFormat; use ruff_linter::{fs, warn_user, warn_user_once}; use ruff_workspace::Settings; -use crate::args::{Args, CheckCommand, Command, FormatCommand}; +use crate::args::{ + AnalyzeCommand, AnalyzeGraphCommand, Args, CheckCommand, Command, FormatCommand, +}; use crate::printer::{Flags as PrinterFlags, Printer}; pub mod args; @@ -186,6 +188,7 @@ pub fn run( Command::Check(args) => check(args, global_options), Command::Format(args) => format(args, global_options), Command::Server(args) => server(args), + Command::Analyze(AnalyzeCommand::Graph(args)) => graph_build(args, global_options), } } @@ -199,6 +202,12 @@ fn format(args: FormatCommand, global_options: GlobalConfigArgs) -> Result Result { + let (cli, config_arguments) = args.partition(global_options)?; + + commands::analyze_graph::analyze_graph(cli, &config_arguments) +} + fn server(args: ServerCommand) -> Result { let four = NonZeroUsize::new(4).unwrap(); diff --git a/crates/ruff/tests/analyze_graph.rs b/crates/ruff/tests/analyze_graph.rs new file mode 100644 index 0000000000000..81901eefc1fac --- /dev/null +++ b/crates/ruff/tests/analyze_graph.rs @@ -0,0 +1,262 @@ +//! Tests the interaction of the `analyze graph` command. + +#![cfg(not(target_family = "wasm"))] + +use assert_fs::prelude::*; +use std::process::Command; +use std::str; + +use anyhow::Result; +use assert_fs::fixture::ChildPath; +use insta_cmd::{assert_cmd_snapshot, get_cargo_bin}; +use tempfile::TempDir; + +fn command() -> Command { + let mut command = Command::new(get_cargo_bin("ruff")); + command.arg("analyze"); + command.arg("graph"); + command.arg("--preview"); + command +} + +const INSTA_FILTERS: &[(&str, &str)] = &[ + // Rewrite Windows output to Unix output + (r"\\", "/"), +]; + +#[test] +fn dependencies() -> Result<()> { + let tempdir = TempDir::new()?; + let root = ChildPath::new(tempdir.path()); + + root.child("ruff").child("__init__.py").write_str("")?; + root.child("ruff") + .child("a.py") + .write_str(indoc::indoc! {r#" + import ruff.b + "#})?; + root.child("ruff") + .child("b.py") + .write_str(indoc::indoc! {r#" + from ruff import c + "#})?; + root.child("ruff") + .child("c.py") + .write_str(indoc::indoc! {r#" + from . import d + "#})?; + root.child("ruff") + .child("d.py") + .write_str(indoc::indoc! {r#" + from .e import f + "#})?; + root.child("ruff") + .child("e.py") + .write_str(indoc::indoc! {r#" + def f(): pass + "#})?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().current_dir(&root), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [ + "ruff/c.py" + ], + "ruff/c.py": [ + "ruff/d.py" + ], + "ruff/d.py": [ + "ruff/e.py" + ], + "ruff/e.py": [] + } + + ----- stderr ----- + "###); + }); + + Ok(()) +} + +#[test] +fn dependents() -> Result<()> { + let tempdir = TempDir::new()?; + + let root = ChildPath::new(tempdir.path()); + + root.child("ruff").child("__init__.py").write_str("")?; + root.child("ruff") + .child("a.py") + .write_str(indoc::indoc! {r#" + import ruff.b + "#})?; + root.child("ruff") + .child("b.py") + .write_str(indoc::indoc! {r#" + from ruff import c + "#})?; + root.child("ruff") + .child("c.py") + .write_str(indoc::indoc! {r#" + from . import d + "#})?; + root.child("ruff") + .child("d.py") + .write_str(indoc::indoc! {r#" + from .e import f + "#})?; + root.child("ruff") + .child("e.py") + .write_str(indoc::indoc! {r#" + def f(): pass + "#})?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().arg("--direction").arg("dependents").current_dir(&root), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [], + "ruff/b.py": [ + "ruff/a.py" + ], + "ruff/c.py": [ + "ruff/b.py" + ], + "ruff/d.py": [ + "ruff/c.py" + ], + "ruff/e.py": [ + "ruff/d.py" + ] + } + + ----- stderr ----- + "###); + }); + + Ok(()) +} + +#[test] +fn string_detection() -> Result<()> { + let tempdir = TempDir::new()?; + + let root = ChildPath::new(tempdir.path()); + + root.child("ruff").child("__init__.py").write_str("")?; + root.child("ruff") + .child("a.py") + .write_str(indoc::indoc! {r#" + import ruff.b + "#})?; + root.child("ruff") + .child("b.py") + .write_str(indoc::indoc! {r#" + import importlib + + importlib.import_module("ruff.c") + "#})?; + root.child("ruff").child("c.py").write_str("")?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().current_dir(&root), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [], + "ruff/c.py": [] + } + + ----- stderr ----- + "###); + }); + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().arg("--detect-string-imports").current_dir(&root), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [ + "ruff/c.py" + ], + "ruff/c.py": [] + } + + ----- stderr ----- + "###); + }); + + Ok(()) +} + +#[test] +fn globs() -> Result<()> { + let tempdir = TempDir::new()?; + + let root = ChildPath::new(tempdir.path()); + + root.child("ruff.toml").write_str(indoc::indoc! {r#" + [analyze] + include-dependencies = { "ruff/a.py" = ["ruff/b.py"], "ruff/b.py" = ["ruff/*.py"] } + "#})?; + + root.child("ruff").child("__init__.py").write_str("")?; + root.child("ruff").child("a.py").write_str("")?; + root.child("ruff").child("b.py").write_str("")?; + root.child("ruff").child("c.py").write_str("")?; + + insta::with_settings!({ + filters => INSTA_FILTERS.to_vec(), + }, { + assert_cmd_snapshot!(command().current_dir(&root), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "ruff/__init__.py": [], + "ruff/a.py": [ + "ruff/b.py" + ], + "ruff/b.py": [ + "ruff/__init__.py", + "ruff/a.py", + "ruff/b.py", + "ruff/c.py" + ], + "ruff/c.py": [] + } + + ----- stderr ----- + "###); + }); + + Ok(()) +} diff --git a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap index 2259a7f4c1c3d..e5ce1e0541ffc 100644 --- a/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap +++ b/crates/ruff/tests/snapshots/show_settings__display_default_settings.snap @@ -200,7 +200,7 @@ linter.safety_table.forced_unsafe = [] linter.target_version = Py37 linter.preview = disabled linter.explicit_preview_rules = false -linter.extension.mapping = {} +linter.extension = ExtensionMapping({}) linter.allowed_confusables = [] linter.builtins = [] linter.dummy_variable_rgx = ^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$ @@ -388,4 +388,10 @@ formatter.magic_trailing_comma = respect formatter.docstring_code_format = disabled formatter.docstring_code_line_width = dynamic +# Analyze Settings +analyze.preview = disabled +analyze.detect_string_imports = false +analyze.extension = ExtensionMapping({}) +analyze.include_dependencies = {} + ----- stderr ----- diff --git a/crates/ruff_db/Cargo.toml b/crates/ruff_db/Cargo.toml index 3ccba5047cee1..570aa0d63b297 100644 --- a/crates/ruff_db/Cargo.toml +++ b/crates/ruff_db/Cargo.toml @@ -26,6 +26,7 @@ filetime = { workspace = true } ignore = { workspace = true, optional = true } matchit = { workspace = true } salsa = { workspace = true } +serde = { workspace = true, optional = true } path-slash = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } @@ -47,5 +48,6 @@ tempfile = { workspace = true } [features] cache = ["ruff_cache"] os = ["ignore"] +serde = ["dep:serde", "camino/serde1"] # Exposes testing utilities. testing = ["tracing-subscriber", "tracing-tree"] diff --git a/crates/ruff_db/src/system/os.rs b/crates/ruff_db/src/system/os.rs index d4ff8bd3926df..6652c4a383db5 100644 --- a/crates/ruff_db/src/system/os.rs +++ b/crates/ruff_db/src/system/os.rs @@ -16,7 +16,7 @@ use super::walk_directory::{ }; /// A system implementation that uses the OS file system. -#[derive(Default, Debug)] +#[derive(Default, Debug, Clone)] pub struct OsSystem { inner: Arc, } diff --git a/crates/ruff_db/src/system/path.rs b/crates/ruff_db/src/system/path.rs index df98280c1de96..25cc854c4397b 100644 --- a/crates/ruff_db/src/system/path.rs +++ b/crates/ruff_db/src/system/path.rs @@ -593,6 +593,27 @@ impl ruff_cache::CacheKey for SystemPathBuf { } } +#[cfg(feature = "serde")] +impl serde::Serialize for SystemPath { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for SystemPathBuf { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for SystemPathBuf { + fn deserialize>(deserializer: D) -> Result { + Utf8PathBuf::deserialize(deserializer).map(SystemPathBuf) + } +} + /// A slice of a virtual path on [`System`](super::System) (akin to [`str`]). #[repr(transparent)] pub struct SystemVirtualPath(str); diff --git a/crates/ruff_graph/Cargo.toml b/crates/ruff_graph/Cargo.toml new file mode 100644 index 0000000000000..601b637873dcd --- /dev/null +++ b/crates/ruff_graph/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "ruff_graph" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +red_knot_python_semantic = { workspace = true } +ruff_cache = { workspace = true } +ruff_db = { workspace = true, features = ["os", "serde"] } +ruff_linter = { workspace = true } +ruff_macros = { workspace = true } +ruff_python_ast = { workspace = true } + +anyhow = { workspace = true } +clap = { workspace = true, optional = true } +salsa = { workspace = true } +schemars = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[lints] +workspace = true + +[package.metadata.cargo-shear] +# Used via `CacheKey` macro expansion. +ignored = ["ruff_cache"] diff --git a/crates/ruff_graph/src/collector.rs b/crates/ruff_graph/src/collector.rs new file mode 100644 index 0000000000000..2ce801c4d4d19 --- /dev/null +++ b/crates/ruff_graph/src/collector.rs @@ -0,0 +1,111 @@ +use red_knot_python_semantic::ModuleName; +use ruff_python_ast::visitor::source_order::{walk_body, walk_expr, walk_stmt, SourceOrderVisitor}; +use ruff_python_ast::{self as ast, Expr, ModModule, Stmt}; + +/// Collect all imports for a given Python file. +#[derive(Default, Debug)] +pub(crate) struct Collector<'a> { + /// The path to the current module. + module_path: Option<&'a [String]>, + /// Whether to detect imports from string literals. + string_imports: bool, + /// The collected imports from the Python AST. + imports: Vec, +} + +impl<'a> Collector<'a> { + pub(crate) fn new(module_path: Option<&'a [String]>, string_imports: bool) -> Self { + Self { + module_path, + string_imports, + imports: Vec::new(), + } + } + + #[must_use] + pub(crate) fn collect(mut self, module: &ModModule) -> Vec { + walk_body(&mut self, &module.body); + self.imports + } +} + +impl<'ast> SourceOrderVisitor<'ast> for Collector<'_> { + fn visit_stmt(&mut self, stmt: &'ast Stmt) { + match stmt { + Stmt::ImportFrom(ast::StmtImportFrom { + names, + module, + level, + range: _, + }) => { + let module = module.as_deref(); + let level = *level; + for alias in names { + let mut components = vec![]; + + if level > 0 { + // If we're resolving a relative import, we must have a module path. + let Some(module_path) = self.module_path else { + return; + }; + + // Start with the containing module. + components.extend(module_path.iter().map(String::as_str)); + + // Remove segments based on the number of dots. + for _ in 0..level { + if components.is_empty() { + return; + } + components.pop(); + } + } + + // Add the module path. + if let Some(module) = module { + components.extend(module.split('.')); + } + + // Add the alias name. + components.push(alias.name.as_str()); + + if let Some(module_name) = ModuleName::from_components(components) { + self.imports.push(CollectedImport::ImportFrom(module_name)); + } + } + } + Stmt::Import(ast::StmtImport { names, range: _ }) => { + for alias in names { + if let Some(module_name) = ModuleName::new(alias.name.as_str()) { + self.imports.push(CollectedImport::Import(module_name)); + } + } + } + _ => { + walk_stmt(self, stmt); + } + } + } + + fn visit_expr(&mut self, expr: &'ast Expr) { + if self.string_imports { + if let Expr::StringLiteral(ast::ExprStringLiteral { value, range: _ }) = expr { + // Determine whether the string literal "looks like" an import statement: contains + // a dot, and consists solely of valid Python identifiers. + let value = value.to_str(); + if let Some(module_name) = ModuleName::new(value) { + self.imports.push(CollectedImport::Import(module_name)); + } + } + walk_expr(self, expr); + } + } +} + +#[derive(Debug)] +pub(crate) enum CollectedImport { + /// The import was part of an `import` statement. + Import(ModuleName), + /// The import was part of an `import from` statement. + ImportFrom(ModuleName), +} diff --git a/crates/ruff_graph/src/db.rs b/crates/ruff_graph/src/db.rs new file mode 100644 index 0000000000000..9e786eee0549b --- /dev/null +++ b/crates/ruff_graph/src/db.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use red_knot_python_semantic::{Db, Program, ProgramSettings, PythonVersion, SearchPathSettings}; +use ruff_db::files::{File, Files}; +use ruff_db::system::{OsSystem, System, SystemPathBuf}; +use ruff_db::vendored::VendoredFileSystem; +use ruff_db::{Db as SourceDb, Upcast}; + +#[salsa::db] +#[derive(Default)] +pub struct ModuleDb { + storage: salsa::Storage, + files: Files, + system: OsSystem, + vendored: VendoredFileSystem, +} + +impl ModuleDb { + /// Initialize a [`ModuleDb`] from the given source root. + pub fn from_src_roots(mut src_roots: impl Iterator) -> Result { + let search_paths = { + // Use the first source root. + let src_root = src_roots + .next() + .ok_or_else(|| anyhow::anyhow!("No source roots provided"))?; + + let mut search_paths = SearchPathSettings::new(src_root.to_path_buf()); + + // Add the remaining source roots as extra paths. + for src_root in src_roots { + search_paths.extra_paths.push(src_root.to_path_buf()); + } + + search_paths + }; + + let db = Self::default(); + Program::from_settings( + &db, + &ProgramSettings { + target_version: PythonVersion::default(), + search_paths, + }, + )?; + + Ok(db) + } + + /// Create a snapshot of the current database. + #[must_use] + pub fn snapshot(&self) -> Self { + Self { + storage: self.storage.clone(), + system: self.system.clone(), + vendored: self.vendored.clone(), + files: self.files.snapshot(), + } + } +} + +impl Upcast for ModuleDb { + fn upcast(&self) -> &(dyn SourceDb + 'static) { + self + } + fn upcast_mut(&mut self) -> &mut (dyn SourceDb + 'static) { + self + } +} + +#[salsa::db] +impl SourceDb for ModuleDb { + fn vendored(&self) -> &VendoredFileSystem { + &self.vendored + } + + fn system(&self) -> &dyn System { + &self.system + } + + fn files(&self) -> &Files { + &self.files + } +} + +#[salsa::db] +impl Db for ModuleDb { + fn is_file_open(&self, file: File) -> bool { + !file.path(self).is_vendored_path() + } +} + +#[salsa::db] +impl salsa::Database for ModuleDb { + fn salsa_event(&self, _event: &dyn Fn() -> salsa::Event) {} +} diff --git a/crates/ruff_graph/src/lib.rs b/crates/ruff_graph/src/lib.rs new file mode 100644 index 0000000000000..3d6f92c7ef3a5 --- /dev/null +++ b/crates/ruff_graph/src/lib.rs @@ -0,0 +1,120 @@ +use crate::collector::Collector; +pub use crate::db::ModuleDb; +use crate::resolver::Resolver; +pub use crate::settings::{AnalyzeSettings, Direction}; +use anyhow::Result; +use red_knot_python_semantic::SemanticModel; +use ruff_db::files::system_path_to_file; +use ruff_db::parsed::parsed_module; +use ruff_db::system::{SystemPath, SystemPathBuf}; +use ruff_python_ast::helpers::to_module_path; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +mod collector; +mod db; +mod resolver; +mod settings; + +#[derive(Debug, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ModuleImports(BTreeSet); + +impl ModuleImports { + /// Insert a file path into the module imports. + pub fn insert(&mut self, path: SystemPathBuf) { + self.0.insert(path); + } + + /// Returns `true` if the module imports are empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Returns the number of module imports. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Convert the file paths to be relative to a given path. + #[must_use] + pub fn relative_to(self, path: &SystemPath) -> Self { + Self( + self.0 + .into_iter() + .map(|import| { + import + .strip_prefix(path) + .map(SystemPath::to_path_buf) + .unwrap_or(import) + }) + .collect(), + ) + } +} + +#[derive(Debug, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ImportMap(BTreeMap); + +impl ImportMap { + /// Insert a module's imports into the map. + pub fn insert(&mut self, path: SystemPathBuf, imports: ModuleImports) { + self.0.insert(path, imports); + } + + /// Reverse the [`ImportMap`], e.g., to convert from dependencies to dependents. + #[must_use] + pub fn reverse(imports: impl IntoIterator) -> Self { + let mut reverse = ImportMap::default(); + for (path, imports) in imports { + for import in imports.0 { + reverse.0.entry(import).or_default().insert(path.clone()); + } + reverse.0.entry(path).or_default(); + } + reverse + } +} + +impl FromIterator<(SystemPathBuf, ModuleImports)> for ImportMap { + fn from_iter>(iter: I) -> Self { + let mut map = ImportMap::default(); + for (path, imports) in iter { + map.0.entry(path).or_default().0.extend(imports.0); + } + map + } +} + +/// Generate the module imports for a given Python file. +pub fn generate( + path: &SystemPath, + package: Option<&SystemPath>, + string_imports: bool, + db: &ModuleDb, +) -> Result { + // Read and parse the source code. + let file = system_path_to_file(db, path)?; + let parsed = parsed_module(db, file); + let module_path = + package.and_then(|package| to_module_path(package.as_std_path(), path.as_std_path())); + let model = SemanticModel::new(db, file); + + // Collect the imports. + let imports = Collector::new(module_path.as_deref(), string_imports).collect(parsed.syntax()); + + // Resolve the imports. + let mut resolved_imports = ModuleImports::default(); + for import in imports { + let Some(resolved) = Resolver::new(&model).resolve(import) else { + continue; + }; + let Some(path) = resolved.as_system_path() else { + continue; + }; + resolved_imports.insert(path.to_path_buf()); + } + + Ok(resolved_imports) +} diff --git a/crates/ruff_graph/src/resolver.rs b/crates/ruff_graph/src/resolver.rs new file mode 100644 index 0000000000000..1de2968eb7278 --- /dev/null +++ b/crates/ruff_graph/src/resolver.rs @@ -0,0 +1,39 @@ +use red_knot_python_semantic::SemanticModel; +use ruff_db::files::FilePath; + +use crate::collector::CollectedImport; + +/// Collect all imports for a given Python file. +pub(crate) struct Resolver<'a> { + semantic: &'a SemanticModel<'a>, +} + +impl<'a> Resolver<'a> { + /// Initialize a [`Resolver`] with a given [`SemanticModel`]. + pub(crate) fn new(semantic: &'a SemanticModel<'a>) -> Self { + Self { semantic } + } + + /// Resolve the [`CollectedImport`] into a [`FilePath`]. + pub(crate) fn resolve(&self, import: CollectedImport) -> Option<&'a FilePath> { + match import { + CollectedImport::Import(import) => self + .semantic + .resolve_module(import) + .map(|module| module.file().path(self.semantic.db())), + CollectedImport::ImportFrom(import) => { + // Attempt to resolve the member (e.g., given `from foo import bar`, look for `foo.bar`). + let parent = import.parent(); + self.semantic + .resolve_module(import) + .map(|module| module.file().path(self.semantic.db())) + .or_else(|| { + // Attempt to resolve the module (e.g., given `from foo import bar`, look for `foo`). + self.semantic + .resolve_module(parent?) + .map(|module| module.file().path(self.semantic.db())) + }) + } + } + } +} diff --git a/crates/ruff_graph/src/settings.rs b/crates/ruff_graph/src/settings.rs new file mode 100644 index 0000000000000..03025b1dc63b4 --- /dev/null +++ b/crates/ruff_graph/src/settings.rs @@ -0,0 +1,52 @@ +use ruff_linter::display_settings; +use ruff_linter::settings::types::{ExtensionMapping, PreviewMode}; +use ruff_macros::CacheKey; +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; + +#[derive(Debug, Default, Clone, CacheKey)] +pub struct AnalyzeSettings { + pub preview: PreviewMode, + pub detect_string_imports: bool, + pub include_dependencies: BTreeMap)>, + pub extension: ExtensionMapping, +} + +impl fmt::Display for AnalyzeSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "\n# Analyze Settings")?; + display_settings! { + formatter = f, + namespace = "analyze", + fields = [ + self.preview, + self.detect_string_imports, + self.extension | debug, + self.include_dependencies | debug, + ] + } + Ok(()) + } +} + +#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, CacheKey)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +pub enum Direction { + /// Construct a map from module to its dependencies (i.e., the modules that it imports). + #[default] + Dependencies, + /// Construct a map from module to its dependents (i.e., the modules that import it). + Dependents, +} + +impl fmt::Display for Direction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Dependencies => write!(f, "\"dependencies\""), + Self::Dependents => write!(f, "\"dependents\""), + } + } +} diff --git a/crates/ruff_linter/src/logging.rs b/crates/ruff_linter/src/logging.rs index 81c733eed2c70..ce89402231ffc 100644 --- a/crates/ruff_linter/src/logging.rs +++ b/crates/ruff_linter/src/logging.rs @@ -152,6 +152,8 @@ pub fn set_up_logging(level: LogLevel) -> Result<()> { }) .level(level.level_filter()) .level_for("globset", log::LevelFilter::Warn) + .level_for("red_knot_python_semantic", log::LevelFilter::Warn) + .level_for("salsa", log::LevelFilter::Warn) .chain(std::io::stderr()) .apply()?; Ok(()) diff --git a/crates/ruff_linter/src/settings/mod.rs b/crates/ruff_linter/src/settings/mod.rs index a0c319bf46641..06e6239bf0c0f 100644 --- a/crates/ruff_linter/src/settings/mod.rs +++ b/crates/ruff_linter/src/settings/mod.rs @@ -285,7 +285,7 @@ impl Display for LinterSettings { self.target_version | debug, self.preview, self.explicit_preview_rules, - self.extension | nested, + self.extension | debug, self.allowed_confusables | array, self.builtins | array, diff --git a/crates/ruff_linter/src/settings/types.rs b/crates/ruff_linter/src/settings/types.rs index 4b632dd5ee15a..eb018883c5d21 100644 --- a/crates/ruff_linter/src/settings/types.rs +++ b/crates/ruff_linter/src/settings/types.rs @@ -478,46 +478,31 @@ impl From for (String, Language) { (value.extension, value.language) } } + #[derive(Debug, Clone, Default, CacheKey)] -pub struct ExtensionMapping { - mapping: FxHashMap, -} +pub struct ExtensionMapping(FxHashMap); impl ExtensionMapping { /// Return the [`Language`] for the given file. pub fn get(&self, path: &Path) -> Option { let ext = path.extension()?.to_str()?; - self.mapping.get(ext).copied() - } -} - -impl Display for ExtensionMapping { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - display_settings! { - formatter = f, - namespace = "linter.extension", - fields = [ - self.mapping | debug - ] - } - Ok(()) + self.0.get(ext).copied() } } impl From> for ExtensionMapping { fn from(value: FxHashMap) -> Self { - Self { mapping: value } + Self(value) } } impl FromIterator for ExtensionMapping { fn from_iter>(iter: T) -> Self { - Self { - mapping: iter - .into_iter() + Self( + iter.into_iter() .map(|pair| (pair.extension, pair.language)) .collect(), - } + ) } } diff --git a/crates/ruff_workspace/Cargo.toml b/crates/ruff_workspace/Cargo.toml index c2b79a8bdde23..81b5f87596747 100644 --- a/crates/ruff_workspace/Cargo.toml +++ b/crates/ruff_workspace/Cargo.toml @@ -13,14 +13,15 @@ license = { workspace = true } [lib] [dependencies] -ruff_linter = { workspace = true } +ruff_cache = { workspace = true } ruff_formatter = { workspace = true } -ruff_python_formatter = { workspace = true, features = ["serde"] } +ruff_graph = { workspace = true, features = ["serde", "schemars"] } +ruff_linter = { workspace = true } +ruff_macros = { workspace = true } ruff_python_ast = { workspace = true } +ruff_python_formatter = { workspace = true, features = ["serde"] } ruff_python_semantic = { workspace = true, features = ["serde"] } ruff_source_file = { workspace = true } -ruff_cache = { workspace = true } -ruff_macros = { workspace = true } anyhow = { workspace = true } colored = { workspace = true } diff --git a/crates/ruff_workspace/src/configuration.rs b/crates/ruff_workspace/src/configuration.rs index f178e91c13182..4657784b68beb 100644 --- a/crates/ruff_workspace/src/configuration.rs +++ b/crates/ruff_workspace/src/configuration.rs @@ -3,6 +3,7 @@ //! the various parameters. use std::borrow::Cow; +use std::collections::BTreeMap; use std::env::VarError; use std::num::{NonZeroU16, NonZeroU8}; use std::path::{Path, PathBuf}; @@ -19,6 +20,7 @@ use strum::IntoEnumIterator; use ruff_cache::cache_dir; use ruff_formatter::IndentStyle; +use ruff_graph::{AnalyzeSettings, Direction}; use ruff_linter::line_width::{IndentWidth, LineLength}; use ruff_linter::registry::RuleNamespace; use ruff_linter::registry::{Rule, RuleSet, INCOMPATIBLE_CODES}; @@ -40,11 +42,11 @@ use ruff_python_formatter::{ }; use crate::options::{ - Flake8AnnotationsOptions, Flake8BanditOptions, Flake8BooleanTrapOptions, Flake8BugbearOptions, - Flake8BuiltinsOptions, Flake8ComprehensionsOptions, Flake8CopyrightOptions, - Flake8ErrMsgOptions, Flake8GetTextOptions, Flake8ImplicitStrConcatOptions, - Flake8ImportConventionsOptions, Flake8PytestStyleOptions, Flake8QuotesOptions, - Flake8SelfOptions, Flake8TidyImportsOptions, Flake8TypeCheckingOptions, + AnalyzeOptions, Flake8AnnotationsOptions, Flake8BanditOptions, Flake8BooleanTrapOptions, + Flake8BugbearOptions, Flake8BuiltinsOptions, Flake8ComprehensionsOptions, + Flake8CopyrightOptions, Flake8ErrMsgOptions, Flake8GetTextOptions, + Flake8ImplicitStrConcatOptions, Flake8ImportConventionsOptions, Flake8PytestStyleOptions, + Flake8QuotesOptions, Flake8SelfOptions, Flake8TidyImportsOptions, Flake8TypeCheckingOptions, Flake8UnusedArgumentsOptions, FormatOptions, IsortOptions, LintCommonOptions, LintOptions, McCabeOptions, Options, Pep8NamingOptions, PyUpgradeOptions, PycodestyleOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions, @@ -142,6 +144,7 @@ pub struct Configuration { pub lint: LintConfiguration, pub format: FormatConfiguration, + pub analyze: AnalyzeConfiguration, } impl Configuration { @@ -207,6 +210,21 @@ impl Configuration { .unwrap_or(format_defaults.docstring_code_line_width), }; + let analyze = self.analyze; + let analyze_preview = analyze.preview.unwrap_or(global_preview); + let analyze_defaults = AnalyzeSettings::default(); + + let analyze = AnalyzeSettings { + preview: analyze_preview, + extension: self.extension.clone().unwrap_or_default(), + detect_string_imports: analyze + .detect_string_imports + .unwrap_or(analyze_defaults.detect_string_imports), + include_dependencies: analyze + .include_dependencies + .unwrap_or(analyze_defaults.include_dependencies), + }; + let lint = self.lint; let lint_preview = lint.preview.unwrap_or(global_preview); @@ -401,6 +419,7 @@ impl Configuration { }, formatter, + analyze, }) } @@ -534,6 +553,10 @@ impl Configuration { options.format.unwrap_or_default(), project_root, )?, + analyze: AnalyzeConfiguration::from_options( + options.analyze.unwrap_or_default(), + project_root, + )?, }) } @@ -573,6 +596,7 @@ impl Configuration { lint: self.lint.combine(config.lint), format: self.format.combine(config.format), + analyze: self.analyze.combine(config.analyze), } } } @@ -1191,6 +1215,45 @@ impl FormatConfiguration { } } } + +#[derive(Clone, Debug, Default)] +pub struct AnalyzeConfiguration { + pub preview: Option, + pub direction: Option, + pub detect_string_imports: Option, + pub include_dependencies: Option)>>, +} + +impl AnalyzeConfiguration { + #[allow(clippy::needless_pass_by_value)] + pub fn from_options(options: AnalyzeOptions, project_root: &Path) -> Result { + Ok(Self { + preview: options.preview.map(PreviewMode::from), + direction: options.direction, + detect_string_imports: options.detect_string_imports, + include_dependencies: options.include_dependencies.map(|dependencies| { + dependencies + .into_iter() + .map(|(key, value)| { + (project_root.join(key), (project_root.to_path_buf(), value)) + }) + .collect::>() + }), + }) + } + + #[must_use] + #[allow(clippy::needless_pass_by_value)] + pub fn combine(self, config: Self) -> Self { + Self { + preview: self.preview.or(config.preview), + direction: self.direction.or(config.direction), + detect_string_imports: self.detect_string_imports.or(config.detect_string_imports), + include_dependencies: self.include_dependencies.or(config.include_dependencies), + } + } +} + pub(crate) trait CombinePluginOptions { #[must_use] fn combine(self, other: Self) -> Self; diff --git a/crates/ruff_workspace/src/options.rs b/crates/ruff_workspace/src/options.rs index cebb6002b92d4..dc8f4dd9a06fd 100644 --- a/crates/ruff_workspace/src/options.rs +++ b/crates/ruff_workspace/src/options.rs @@ -1,11 +1,14 @@ use regex::Regex; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; use strum::IntoEnumIterator; use crate::options_base::{OptionsMetadata, Visit}; use crate::settings::LineEnding; use ruff_formatter::IndentStyle; +use ruff_graph::Direction; use ruff_linter::line_width::{IndentWidth, LineLength}; use ruff_linter::rules::flake8_import_conventions::settings::BannedAliases; use ruff_linter::rules::flake8_pytest_style::settings::SettingsError; @@ -433,6 +436,10 @@ pub struct Options { /// Options to configure code formatting. #[option_group] pub format: Option, + + /// Options to configure import map generation. + #[option_group] + pub analyze: Option, } /// Configures how Ruff checks your code. @@ -3306,6 +3313,59 @@ pub struct FormatOptions { pub docstring_code_line_length: Option, } +/// Configures Ruff's `analyze` command. +#[derive( + Clone, Debug, PartialEq, Eq, Default, Deserialize, Serialize, OptionsMetadata, CombineOptions, +)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct AnalyzeOptions { + /// Whether to enable preview mode. When preview mode is enabled, Ruff will expose unstable + /// commands. + #[option( + default = "false", + value_type = "bool", + example = r#" + # Enable preview features. + preview = true + "# + )] + pub preview: Option, + /// Whether to generate a map from file to files that it depends on (dependencies) or files that + /// depend on it (dependents). + #[option( + default = r#"\"dependencies\""#, + value_type = "\"dependents\" | \"dependencies\"", + example = r#" + direction = "dependencies" + "# + )] + pub direction: Option, + /// Whether to detect imports from string literals. When enabled, Ruff will search for string + /// literals that "look like" import paths, and include them in the import map, if they resolve + /// to valid Python modules. + #[option( + default = "false", + value_type = "bool", + example = r#" + detect-string-imports = true + "# + )] + pub detect_string_imports: Option, + /// A map from file path to the list of file paths or globs that should be considered + /// dependencies of that file, regardless of whether relevant imports are detected. + #[option( + default = "{}", + value_type = "dict[str, list[str]]", + example = r#" + include-dependencies = { + "foo/bar.py": ["foo/baz/*.py"], + } + "# + )] + pub include_dependencies: Option>>, +} + #[cfg(test)] mod tests { use crate::options::Flake8SelfOptions; diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs index 42687be4d5c54..04750c8509362 100644 --- a/crates/ruff_workspace/src/resolver.rs +++ b/crates/ruff_workspace/src/resolver.rs @@ -395,7 +395,6 @@ pub fn python_files_in_path<'a>( let walker = builder.build_parallel(); // Run the `WalkParallel` to collect all Python files. - let state = WalkPythonFilesState::new(resolver); let mut visitor = PythonFilesVisitorBuilder::new(transformer, &state); walker.visit(&mut visitor); diff --git a/crates/ruff_workspace/src/settings.rs b/crates/ruff_workspace/src/settings.rs index aee85fb84f469..451d9d8a104ae 100644 --- a/crates/ruff_workspace/src/settings.rs +++ b/crates/ruff_workspace/src/settings.rs @@ -1,6 +1,7 @@ use path_absolutize::path_dedot; use ruff_cache::cache_dir; use ruff_formatter::{FormatOptions, IndentStyle, IndentWidth, LineWidth}; +use ruff_graph::AnalyzeSettings; use ruff_linter::display_settings; use ruff_linter::settings::types::{ ExtensionMapping, FilePattern, FilePatternSet, OutputFormat, UnsafeFixes, @@ -35,6 +36,7 @@ pub struct Settings { pub file_resolver: FileResolverSettings, pub linter: LinterSettings, pub formatter: FormatterSettings, + pub analyze: AnalyzeSettings, } impl Default for Settings { @@ -50,6 +52,7 @@ impl Default for Settings { linter: LinterSettings::new(project_root), file_resolver: FileResolverSettings::new(project_root), formatter: FormatterSettings::default(), + analyze: AnalyzeSettings::default(), } } } @@ -68,7 +71,8 @@ impl fmt::Display for Settings { self.unsafe_fixes, self.file_resolver | nested, self.linter | nested, - self.formatter | nested + self.formatter | nested, + self.analyze | nested, ] } Ok(()) diff --git a/docs/configuration.md b/docs/configuration.md index 6f2ee8e638dcc..94b53c3ae662f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -522,6 +522,7 @@ Commands: clean Clear any caches in the current directory and any subdirectories format Run the Ruff formatter on the given files or directories server Run the language server + analyze Run analysis over Python source code version Display Ruff's version help Print this message or the help of the given subcommand(s) diff --git a/ruff.schema.json b/ruff.schema.json index ed2f77e1dafdf..c4adb82957e41 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -16,6 +16,17 @@ "minLength": 1 } }, + "analyze": { + "description": "Options to configure import map generation.", + "anyOf": [ + { + "$ref": "#/definitions/AnalyzeOptions" + }, + { + "type": "null" + } + ] + }, "builtins": { "description": "A list of builtins to treat as defined references, in addition to the system builtins.", "type": [ @@ -746,6 +757,51 @@ }, "additionalProperties": false, "definitions": { + "AnalyzeOptions": { + "description": "Configures Ruff's `analyze` command.", + "type": "object", + "properties": { + "detect-string-imports": { + "description": "Whether to detect imports from string literals. When enabled, Ruff will search for string literals that \"look like\" import paths, and include them in the import map, if they resolve to valid Python modules.", + "type": [ + "boolean", + "null" + ] + }, + "direction": { + "description": "Whether to generate a map from file to files that it depends on (dependencies) or files that depend on it (dependents).", + "anyOf": [ + { + "$ref": "#/definitions/Direction" + }, + { + "type": "null" + } + ] + }, + "include-dependencies": { + "description": "A map from file path to the list of file paths or globs that should be considered dependencies of that file, regardless of whether relevant imports are detected.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "preview": { + "description": "Whether to enable preview mode. When preview mode is enabled, Ruff will expose unstable commands.", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, "ApiBan": { "type": "object", "required": [ @@ -800,6 +856,24 @@ } ] }, + "Direction": { + "oneOf": [ + { + "description": "Construct a map from module to its dependencies (i.e., the modules that it imports).", + "type": "string", + "enum": [ + "Dependencies" + ] + }, + { + "description": "Construct a map from module to its dependents (i.e., the modules that import it).", + "type": "string", + "enum": [ + "Dependents" + ] + } + ] + }, "DocstringCodeLineWidth": { "anyOf": [ {