diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 347bbc29bd..2bc0d2bcfe 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -503,7 +503,12 @@ jobs:
# work — and the 1942s run reached 45m mid-way through the dogfood steps and was killed.
# Nextest's own total is the number to compare here: it stays flat across the merge, so the
# budget is for the runner's variance, not for the suite growing.
- timeout-minutes: 75
+ #
+ # 75 in turn was overrun on 2026-09-23 by two runs of the same code a few hours apart:
+ # one finished in 46 minutes, and the other, on a newer runner image, summed 244 minutes
+ # of test time against 125 for the fast one and was killed at test 15000 of 15397. A
+ # machine half as fast is within the variance seen, and it takes the job past 90 minutes
+ timeout-minutes: 120
env:
# Line-tables-only debug info: faster builds, backtraces still work.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
@@ -660,7 +665,12 @@ jobs:
# windows is the slow half of this matrix and came within two minutes of the previous
# 45-minute budget on 2026-08-13 (42m54s, against 24m56s for macos on the same run), so it
# gets the same headroom the linux job needed for the same reason
- timeout-minutes: 75
+ #
+ # that figure predates windows pinning an interpreter, which is what lets `by_build`
+ # compile anything there. with it, `by_build` alone is 77 of the 249 minutes of test time,
+ # and a whole run took 74m50s on 2026-09-23 on an ordinary runner — no headroom left at
+ # 75 for the variance the linux job sees
+ timeout-minutes: 120
env:
# Line-tables-only debug info: faster builds, backtraces still work.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
diff --git a/Cargo.lock b/Cargo.lock
index 9ca868479f..9c2cc0537c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -435,6 +435,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"by_transforms",
+ "dunce",
"rayon",
"ruff_db",
"serde",
@@ -4964,6 +4965,7 @@ dependencies = [
"ty_python_semantic",
"ty_static",
"ty_vendored",
+ "unicode-width",
]
[[package]]
diff --git a/crates/by_stage/Cargo.toml b/crates/by_stage/Cargo.toml
index 6011aa89c3..40775522f1 100644
--- a/crates/by_stage/Cargo.toml
+++ b/crates/by_stage/Cargo.toml
@@ -21,6 +21,7 @@ ty_project = { workspace = true }
ty_python_semantic = { workspace = true }
anyhow = { workspace = true }
+dunce = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
diff --git a/crates/by_stage/src/emit.rs b/crates/by_stage/src/emit.rs
index f4f3e3c52c..3357ad542e 100644
--- a/crates/by_stage/src/emit.rs
+++ b/crates/by_stage/src/emit.rs
@@ -1,7 +1,7 @@
//! Checking a project's sources and turning the ones that pass into python.
//!
//! The one transpile path. `by run`, `by build`, `by transpile
` and the
-//! server's single-file re-stage all arrive here, which is the point: two paths
+//! server's re-stage of an edit all arrive here, which is the point: two paths
//! could disagree about what a file lowers to, and a build tree whose modules were
//! written by one and re-staged by the other is a tree the debugger will refuse —
//! or worse, accept while the line table describes something else.
@@ -130,9 +130,16 @@ pub fn check_and_transpile(
// batching files into fewer jobs measured 4.5x slower, and cloning only every
// 32 files gives the saving up entirely — so anything that reduces the number
// of clones silently undoes this
+ //
+ // ignoring the check mode, because these files are what the output is made of:
+ // a language server whose mode reports diagnostics only for open files still
+ // has to refuse a closed one that does not check, before its bytes are written
+ // into a tree a program is running out of
let checked: Vec> = handles
.par_iter()
- .map_with_db(db, |db, (_, file)| db.check_file(*file))
+ .map_with_db(db, |db, (_, file)| {
+ ty_project::check_file_ignoring_check_mode(db, *file)
+ })
.collect();
for ((_, file), diags) in handles.iter().zip(checked) {
if diags.iter().any(is_unusable_source) {
diff --git a/crates/by_stage/src/layout.rs b/crates/by_stage/src/layout.rs
new file mode 100644
index 0000000000..1a7ecf59a1
--- /dev/null
+++ b/crates/by_stage/src/layout.rs
@@ -0,0 +1,112 @@
+//! Where a project's build writes, and which file in it came from which source.
+//!
+//! An editor needs this for everything that relates a `.by` to what it becomes: opening the
+//! generated module, going back from a generated module to its source, keeping the output out of
+//! the editor's own index, telling a test runner not to collect a second copy of every test. Each of
+//! those used to be an editor's guess — `out/.py` — and every part of
+//! that guess went wrong: the directory is `build`, and the path inside it follows the *module*
+//! tree, not the directory tree (see [`crate::staging::transpiled_destination`]). The build is the
+//! only thing that knows, so this is the build's answer, asked of the same database and the same
+//! file set a build reads.
+
+use std::path::{Path, PathBuf};
+
+use ty_project::{Db as _, ProjectDatabase};
+
+use crate::project::{BY_SOURCES, module_roots, project_sources};
+use crate::staging::transpiled_destination;
+
+/// The directory `by build` and `by compile` write to when they are not given `--out`, relative
+/// to the project root — see [`default_build_directory`].
+const DEFAULT_OUTPUT_DIRECTORY: &str = "build";
+
+/// Where the project rooted at `project_root` is built when no `--out` names somewhere else.
+///
+/// Relative to the project, not to wherever a command happens to run: the output belongs to the
+/// project the way its sources and its `.venv` do, so `by build` in `tests/` writes the same tree
+/// as `by build` at the root, and the one place an editor is told the build writes is the place
+/// it does. An explicit `--out` is the caller's own path and stays relative to where they typed it.
+pub fn default_build_directory(project_root: &Path) -> PathBuf {
+ project_root.join(DEFAULT_OUTPUT_DIRECTORY)
+}
+
+/// A path's place in its project's build.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BuildOutput {
+ /// The project root: the project a `by build` run anywhere inside it builds.
+ project_root: PathBuf,
+
+ /// Where `by build`, run anywhere in the project with no `--out`, writes the project.
+ build_directory: PathBuf,
+
+ /// When the path is a `.by` or `.byi` the build is made of, the file it is written to.
+ generated: Option,
+
+ /// When the path is a file in [`Self::build_directory`] that a source is written to, that
+ /// source.
+ source: Option,
+}
+
+/// Where `path` stands in the build of the project `db` holds.
+///
+/// Answers for any path, including one that does not exist yet — a generated file before the first
+/// build is still the place that build will write. Neither side of the mapping is checked against
+/// the disk.
+pub fn build_output(db: &ProjectDatabase, path: &Path) -> BuildOutput {
+ let declared_root = db.project().root(db).as_std_path();
+ let root = canonical(declared_root);
+ let build_directory = default_build_directory(&root);
+ let roots = module_roots(db, &root);
+ let sources = project_sources(db, BY_SOURCES, &root, Some(&build_directory));
+ let wanted = canonical(path);
+
+ // resolved once for the whole sweep. both lookups below compare against every source, and
+ // `canonical` walks a path making a syscall per level, so resolving inside them would ask
+ // the file system the same questions once per source per lookup
+ let resolved: Vec<(PathBuf, PathBuf)> = sources
+ .iter()
+ .map(|(source, _)| {
+ let source = canonical(source);
+ let destination = transpiled_destination(&roots, &root, &source);
+ (source, destination)
+ })
+ .collect();
+
+ let generated = resolved
+ .iter()
+ .find(|(source, _)| *source == wanted)
+ .map(|(_, destination)| build_directory.join(destination));
+
+ let source = wanted
+ .strip_prefix(&build_directory)
+ .ok()
+ .and_then(|relative| {
+ resolved
+ .iter()
+ .find(|(_, destination)| destination == relative)
+ .map(|(source, _)| source.clone())
+ });
+
+ BuildOutput {
+ project_root: root,
+ build_directory,
+ generated,
+ source,
+ }
+}
+
+/// `path` with its links resolved, or as given when it cannot be — a path that does not exist yet is
+/// resolved through its nearest parent that does, so it still compares equal to the resolved form.
+///
+/// On Windows resolving answers with a verbatim `\\?\C:\…` path, which is dropped back to the
+/// `C:\…` spelling a client sends and a client can open.
+fn canonical(path: &Path) -> PathBuf {
+ if let Ok(resolved) = std::fs::canonicalize(path) {
+ return dunce::simplified(&resolved).to_path_buf();
+ }
+ match (path.parent(), path.file_name()) {
+ (Some(parent), Some(name)) => canonical(parent).join(name),
+ _ => path.to_path_buf(),
+ }
+}
diff --git a/crates/by_stage/src/lib.rs b/crates/by_stage/src/lib.rs
index 2442af103e..8a86c944b5 100644
--- a/crates/by_stage/src/lib.rs
+++ b/crates/by_stage/src/lib.rs
@@ -43,12 +43,19 @@
//! entry at a time by a re-stage.
//! - [`record`] — `_by_build.json`, what a tree says about the build that wrote
//! it.
-//! - [`restage`] — one file's slot in an existing tree, recomputed.
+//! - [`restage`] — the slots of an edit's files in an existing tree, and the
+//! one sourcemap describing them, recomputed.
+//! - [`layout`] — where a project's build writes, and which output came from
+//! which source, for an editor to ask.
+//! - [`run_module`] — which file `by run ` executes, and the module a
+//! file runs as.
pub mod emit;
+pub mod layout;
pub mod project;
pub mod record;
pub mod restage;
+pub mod run_module;
pub mod runtime;
pub mod sourcemap;
pub mod staging;
diff --git a/crates/by_stage/src/restage.rs b/crates/by_stage/src/restage.rs
index 23674a2448..c7af1e95bb 100644
--- a/crates/by_stage/src/restage.rs
+++ b/crates/by_stage/src/restage.rs
@@ -1,4 +1,4 @@
-//! Re-staging one file into a build tree that already exists.
+//! Re-staging a set of edited files into a build tree that already exists.
//!
//! A build tree is what actually runs. `by run` transpiles the project into a
//! temp directory, copies every other project file into it, and runs the program
@@ -12,7 +12,7 @@
//! that is about 165 milliseconds. A button press can afford the second number and
//! not the first, and the difference is the entire reason this operation exists.
//!
-//! Two rules shape everything below.
+//! Three rules shape everything below.
//!
//! **It writes nothing.** The result is the bytes and where they go. The plugin
//! writes them, because the plugin is the only party that can roll that write back
@@ -20,12 +20,22 @@
//! replacement the debugger then refused is a tree that lies about what is
//! running.
//!
+//! **It answers for the whole set at once.** `_by_sourcemap.py` is one file
+//! holding an entry for every transpiled module, so a set of edits has one map,
+//! not one per file. Answering each file on its own would hand back a map per
+//! file, each holding the tree's map plus that one file's entry — and whichever a
+//! caller wrote last would silently drop every other edit's line table. So the
+//! set goes in as a set and comes back with one map that carries all of it.
+//!
//! **It refuses rather than guesses.** Every refusal below is a case where the
//! bytes produced would not be the bytes the build would have produced, or where
//! the tree cannot be told what it is. A refusal costs the user a rebuild; a wrong
//! answer costs them a debug session that reports lines from a file that no longer
-//! exists.
+//! exists. One file refused refuses the set, for the reason the debugger applies a
+//! replacement that way: a tree holding half of an edit describes a program that
+//! never existed.
+use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ruff_db::diagnostic::{
@@ -48,14 +58,12 @@ use crate::verbatim::verbatim_destination;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Restaged {
+ /// the file in the project this was produced from, as it was asked about
+ source: PathBuf,
/// where the bytes go: absolute, inside the build directory
generated: PathBuf,
/// the full text to write there
content: String,
- /// the full new text of `_by_sourcemap.py`, or `None` when nothing about the
- /// map changed — including for a file that has no entry in it, which is every
- /// file the build copied rather than transpiled
- sourcemap: Option,
/// sha-256 of the source bytes this was produced from
by_digest: String,
/// sha-256 of `content`
@@ -70,16 +78,43 @@ pub struct Restaged {
changed: bool,
}
-/// Why nothing was produced.
+/// Every slot of the set, and the one map that describes all of them.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RestagedSet {
+ /// one per distinct file asked about, in the order they were asked
+ files: Vec,
+ /// the full new text of `_by_sourcemap.py` with the entry of every
+ /// transpiled file of the set moved in it, or `None` when nothing about the
+ /// map changed — which includes a set of nothing but files the build copied
+ /// rather than transpiled, since none of those has an entry
+ sourcemap: Option,
+}
+
+/// Why nothing was produced for one file, or for the tree as a whole.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Refusal {
+ /// the file this is about, or `None` when it is about the tree — a record
+ /// that cannot be read, a tree another `by` built — and so about every file
+ file: Option,
/// one sentence, written for a user rather than for a log
- pub refused: String,
+ refused: String,
/// the diagnostics behind it, when the refusal was the check gate. empty
/// otherwise
#[serde(default)]
- pub diagnostics: Vec,
+ diagnostics: Vec,
+}
+
+/// Every reason the set was refused.
+///
+/// All of them rather than the first: a user who fixes one file and presses
+/// reload only to be told about the next has been made to find them one at a
+/// time.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Refused {
+ refusals: Vec,
}
/// The answer, either way.
@@ -91,32 +126,50 @@ pub struct Refusal {
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum Restage {
- Ready(Restaged),
- Refused(Refusal),
+ Ready(RestagedSet),
+ Refused(Refused),
}
impl Restage {
- fn refuse(reason: impl Into) -> Self {
- Self::Refused(Refusal {
- refused: reason.into(),
- diagnostics: Vec::new(),
+ /// The whole set refused, for a reason that is not about any one file.
+ pub fn refuse(reason: impl Into) -> Self {
+ Self::Refused(Refused {
+ refusals: vec![Refusal {
+ file: None,
+ refused: reason.into(),
+ diagnostics: Vec::new(),
+ }],
})
}
}
-/// Produce what `file`'s slot in `build_directory` should now contain.
+/// What the tree and the project say, read once for the whole set.
+struct Tree<'a> {
+ db: &'a ProjectDatabase,
+ directory: &'a Path,
+ record: BuildRecord,
+ config: by_transforms::config::Config,
+ rebuilder: Rebuilder,
+ /// the build's own source files, asked the way the build asks, each beside the path with
+ /// its links resolved — worked out once, because every file of the set is looked up against
+ /// all of them and resolving a path costs a syscall
+ sources: Vec<(PathBuf, ruff_db::files::File, PathBuf)>,
+}
+
+/// Produce what each of `files`' slots in `build_directory` should now contain,
+/// and the one `_by_sourcemap.py` that describes them together.
///
-/// `db` is the project database the file belongs to. The language server passes
+/// `db` is the project database the files belong to. The language server passes
/// its own, warm and already indexed, which is the whole reason this is fast
/// enough to sit behind a button; the CLI builds one first. Either way the source
/// text comes out of the db, so an editor's unsaved buffer is what gets
/// transpiled — the answer the user means by "reload this". The digest recorded
/// for the `.by` is then over that buffer, so an unsaved file's tracebacks read as
/// stale until it is saved, which is the honest report rather than a wrong line.
-pub fn restage_one(
+pub fn restage(
db: &ProjectDatabase,
build_directory: &Path,
- file: &Path,
+ files: &[PathBuf],
) -> anyhow::Result {
// absolute from here down, whatever the caller spelled. `generated` is a path
// the caller writes bytes to and `_by_sourcemap.py` keys its tables by the
@@ -134,6 +187,12 @@ pub fn restage_one(
&owned
};
+ if files.is_empty() {
+ return Ok(Restage::refuse(
+ "no files were named, and an empty set has nothing in it to reload",
+ ));
+ }
+
let record = match BuildRecord::read(build_directory) {
Ok(record) => record,
Err(error) => return Ok(Restage::refuse(error.to_string())),
@@ -163,60 +222,140 @@ pub fn restage_one(
Err(error) => return Ok(Restage::refuse(error.to_string())),
};
- let is_source = file
- .extension()
- .and_then(std::ffi::OsStr::to_str)
- .is_some_and(|extension| BY_SOURCES.contains(&extension));
- if is_source {
- restage_transpiled(db, build_directory, &record, &config, file)
+ // one file named twice — or under two spellings of one path — is one slot,
+ // and answering for it twice would hand a caller two writes to one file
+ let mut seen = HashSet::new();
+ let files: Vec<&Path> = files
+ .iter()
+ .filter(|file| seen.insert(std::fs::canonicalize(file).unwrap_or_else(|_| (*file).clone())))
+ .map(PathBuf::as_path)
+ .collect();
+
+ // read once for the set, and only when the set holds something the map
+ // describes: a set of copied files has no line table to move
+ let existing_map = if files.iter().any(|file| is_source(file)) {
+ match std::fs::read_to_string(build_directory.join(BY_SOURCEMAP_FILENAME)) {
+ Ok(existing) => Some(existing),
+ Err(error) => {
+ return Ok(Restage::refuse(format!(
+ "`{}` has no {BY_SOURCEMAP_FILENAME}, so a reloaded module would have no \
+ line table: {error}",
+ build_directory.display(),
+ )));
+ }
+ }
} else {
- restage_verbatim(db, build_directory, &record, file)
+ None
+ };
+
+ let tree = Tree {
+ db,
+ directory: build_directory,
+ sources: project_sources(db, BY_SOURCES, &record.project_root, Some(build_directory))
+ .into_iter()
+ .map(|(path, file)| {
+ let resolved = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
+ (path, file, resolved)
+ })
+ .collect(),
+ rebuilder: Rebuilder::for_project(db),
+ record,
+ config,
+ };
+
+ // every entry of the set moved in one text, one after another. each move
+ // substitutes that entry's two lines and nothing else, so the order they are
+ // applied in cannot change the result
+ let mut merged = existing_map.clone();
+ let mut restaged = Vec::with_capacity(files.len());
+ let mut refusals = Vec::new();
+ for file in files {
+ // which of the two a file goes through is decided by the file, never by whether a map
+ // happened to be read: a `.by` staged verbatim would copy basedpython into the tree
+ let outcome = if is_source(file) {
+ let Some(map) = merged.as_mut() else {
+ return Ok(Restage::refuse(format!(
+ "`{}` is a source file and the set was read without a {BY_SOURCEMAP_FILENAME}",
+ file.display(),
+ )));
+ };
+ restage_transpiled(&tree, file, map)?
+ } else {
+ restage_verbatim(&tree, file)?
+ };
+ match outcome {
+ Ok(one) => restaged.push(one),
+ Err(refusal) => refusals.push(refusal),
+ }
+ }
+
+ if !refusals.is_empty() {
+ return Ok(Restage::Refused(Refused { refusals }));
+ }
+ Ok(Restage::Ready(RestagedSet {
+ files: restaged,
+ sourcemap: merged.filter(|merged| Some(merged) != existing_map.as_ref()),
+ }))
+}
+
+/// Whether the build transpiled `file` rather than copying it.
+fn is_source(file: &Path) -> bool {
+ file.extension()
+ .and_then(std::ffi::OsStr::to_str)
+ .is_some_and(|extension| BY_SOURCES.contains(&extension))
+}
+
+/// A refusal about one file of the set.
+fn refuse_file(file: &Path, refused: String, diagnostics: Vec) -> Refusal {
+ Refusal {
+ file: Some(file.to_path_buf()),
+ refused,
+ diagnostics,
}
}
-/// A `.by`: transpile it as the build would have, and move its one entry in the
-/// sourcemap.
+/// A `.by`: transpile it as the build would have, and move its one entry in
+/// `map`, the set's sourcemap so far.
fn restage_transpiled(
- db: &ProjectDatabase,
- build_directory: &Path,
- record: &BuildRecord,
- config: &by_transforms::config::Config,
+ tree: &Tree<'_>,
file: &Path,
-) -> anyhow::Result {
- // the build's own file set, asked the way the build asks it — so a source the
- // project excludes is refused here rather than transpiled into a slot the
- // build never wrote
- let sources = project_sources(db, BY_SOURCES, &record.project_root, Some(build_directory));
+ map: &mut String,
+) -> anyhow::Result> {
+ let db = tree.db;
+ // the build's own file set, so a source the project excludes is refused here
+ // rather than transpiled into a slot the build never wrote
let wanted = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
- let Some(handle) = sources.into_iter().find(|(path, _)| {
- *path == wanted || std::fs::canonicalize(path).is_ok_and(|path| path == wanted)
- }) else {
- return Ok(Restage::refuse(format!(
- "`{}` is not one of the files `{}` was built from",
- file.display(),
- build_directory.display(),
+ let Some((source, source_file, _)) = tree
+ .sources
+ .iter()
+ .find(|(path, _, resolved)| *path == wanted || *resolved == wanted)
+ else {
+ return Ok(Err(refuse_file(
+ file,
+ format!(
+ "`{}` is not one of the files `{}` was built from",
+ file.display(),
+ tree.directory.display(),
+ ),
+ Vec::new(),
)));
};
// the path the *db* holds, not the caller's spelling of it, so the destination
// is computed against roots recorded in the same form
- let relative = transpiled_destination(&record.module_roots, &record.project_root, &handle.0);
-
- let existing_map = match std::fs::read_to_string(build_directory.join(BY_SOURCEMAP_FILENAME)) {
- Ok(existing) => existing,
- Err(error) => {
- return Ok(Restage::refuse(format!(
- "`{}` has no {BY_SOURCEMAP_FILENAME}, so a reloaded module would have no line \
- table: {error}",
- build_directory.display(),
- )));
- }
- };
- let Some(key) = sourcemap_key_for(&existing_map, &relative) else {
- return Ok(Restage::refuse(format!(
- "`{}` has no entry in {BY_SOURCEMAP_FILENAME} for `{}`, so it is not part of that build",
- build_directory.display(),
- file.display(),
+ let relative =
+ transpiled_destination(&tree.record.module_roots, &tree.record.project_root, source);
+
+ let Some(key) = sourcemap_key_for(map, &relative) else {
+ return Ok(Err(refuse_file(
+ file,
+ format!(
+ "`{}` has no entry in {BY_SOURCEMAP_FILENAME} for `{}`, so it is not part of that \
+ build",
+ tree.directory.display(),
+ file.display(),
+ ),
+ Vec::new(),
)));
};
@@ -226,22 +365,21 @@ fn restage_transpiled(
let mut produced: Option<(crate::sourcemap::TracebackEntry, String)> = None;
let outcome = check_and_transpile(
db,
- std::slice::from_ref(&handle),
+ std::slice::from_ref(&(source.clone(), *source_file)),
&mut Emit {
- config,
+ config: &tree.config,
// the gate `by run` uses: a program that fails `by check` must not
// run, and a module reloaded into a running one is that program
// continuing
gate: CheckGate::AllErrors,
- rebuilder: &Rebuilder::for_project(db),
+ rebuilder: &tree.rebuilder,
requirements: &mut by_transforms::RuntimeRequirements::default(),
- // a re-stage writes one module back into a tree the build already
- // laid out, so what it claims is thrown away: whatever copy of the
- // runtime that module imports is already sitting where the build
- // put it
+ // a re-stage writes modules back into a tree the build already laid
+ // out, so what it claims is thrown away: whatever copy of the runtime
+ // a module imports is already sitting where the build put it
runtime: Some(&mut RuntimeLayout::default()),
- roots: &record.module_roots,
- root: &record.project_root,
+ roots: &tree.record.module_roots,
+ root: &tree.record.project_root,
},
|emitted: &Transpiled<'_>| {
produced = Some((describe_module(emitted), emitted.python.to_owned()));
@@ -250,29 +388,30 @@ fn restage_transpiled(
)?;
let Some((mut entry, python)) = produced.filter(|_| outcome.ok) else {
- return Ok(Restage::Refused(Refusal {
- refused: format!(
+ return Ok(Err(refuse_file(
+ file,
+ format!(
"`{}` does not check, so it cannot be reloaded into a running program",
file.display(),
),
- diagnostics: render_diagnostics(db, &outcome.diagnostics),
- }));
+ render_diagnostics(db, &outcome.diagnostics),
+ )));
};
// the key the map already holds, so the rewritten file differs in the two
// lines that had to move and in nothing else
entry.py_path = key;
- let rewritten = rewrite_sourcemap_entry(&existing_map, &entry).map_err(|refusal| {
+ *map = rewrite_sourcemap_entry(map, &entry).map_err(|refusal| {
anyhow::anyhow!(
"the sourcemap entry vanished between being found and being written: {refusal:?}"
)
})?;
- let generated = build_directory.join(&relative);
- Ok(Restage::Ready(Restaged {
+ let generated = tree.directory.join(&relative);
+ Ok(Ok(Restaged {
+ source: file.to_path_buf(),
changed: differs_on_disk(&generated, &entry.py_digest),
content: python,
- sourcemap: (rewritten != existing_map).then_some(rewritten),
by_digest: entry.by_digest,
py_digest: entry.py_digest,
generated,
@@ -281,23 +420,22 @@ fn restage_transpiled(
/// A hand-written `.py`, or anything else the build copies: its own bytes, at the
/// place the build copied them to.
-fn restage_verbatim(
- db: &ProjectDatabase,
- build_directory: &Path,
- record: &BuildRecord,
- file: &Path,
-) -> anyhow::Result {
+fn restage_verbatim(tree: &Tree<'_>, file: &Path) -> anyhow::Result> {
let Some(relative) = verbatim_destination(
- db,
- &record.project_root,
- &record.module_roots,
- build_directory,
+ tree.db,
+ &tree.record.project_root,
+ &tree.record.module_roots,
+ tree.directory,
file,
) else {
- return Ok(Restage::refuse(format!(
- "`{}` is not one of the files `{}` was built from",
- file.display(),
- build_directory.display(),
+ return Ok(Err(refuse_file(
+ file,
+ format!(
+ "`{}` is not one of the files `{}` was built from",
+ file.display(),
+ tree.directory.display(),
+ ),
+ Vec::new(),
)));
};
@@ -307,22 +445,25 @@ fn restage_verbatim(
// encoding json has no way to hold is one the build can stage and this cannot,
// and saying so is better than handing back something lossy
let Ok(content) = String::from_utf8(bytes) else {
- return Ok(Restage::refuse(format!(
- "`{}` is not utf-8, so its bytes cannot be sent back as text — rebuild to pick it up",
- file.display(),
+ return Ok(Err(refuse_file(
+ file,
+ format!(
+ "`{}` is not utf-8, so its bytes cannot be sent back as text — rebuild to pick \
+ it up",
+ file.display(),
+ ),
+ Vec::new(),
)));
};
// one set of bytes, so one digest under both names: the file the build read
// and the file it wrote are the same file
let digest = content_digest(content.as_bytes());
- let generated = build_directory.join(&relative);
- Ok(Restage::Ready(Restaged {
+ let generated = tree.directory.join(&relative);
+ Ok(Ok(Restaged {
+ source: file.to_path_buf(),
changed: differs_on_disk(&generated, &digest),
content,
- // a copied file has no line table to move, and a map rewritten to say so
- // would be a map that changed for nothing
- sourcemap: None,
by_digest: digest.clone(),
py_digest: digest,
generated,
diff --git a/crates/by_stage/src/run_module.rs b/crates/by_stage/src/run_module.rs
new file mode 100644
index 0000000000..59af8cea21
--- /dev/null
+++ b/crates/by_stage/src/run_module.rs
@@ -0,0 +1,77 @@
+//! Which file `by run ` executes, and which module a file runs as.
+//!
+//! `by run` stages every first-party module into a tree mirroring the module
+//! tree (see [`crate::staging::transpiled_destination`]) and hands the name to
+//! `runpy` there. So a name means what the project's resolver makes of it,
+//! restricted to the module roots the build stages — and an editor asking "what
+//! does `by run app.cli` run" or "what do I pass `by run` to run this file" has
+//! to be told that, not a walk of the directory tree that agrees with it most of
+//! the time.
+//!
+//! One name is one file here. Two files that build to the same module — a
+//! `main.by` beside a `main.py`, or `main.by` at the project root beside
+//! `src/main.by` — are refused by the staging step itself, so neither runs until
+//! one is renamed; until then the name is reported for the file the resolver
+//! finds first, and the other runs under none.
+
+use std::path::Path;
+
+use ruff_db::files::File;
+use ty_module_resolver::{ModuleName, file_to_module, resolve_module_confident};
+use ty_project::{Db as _, ProjectDatabase};
+use ty_python_semantic::Db as _;
+
+use crate::project::module_roots;
+
+/// The module `by run` executes when none is named: the project's `run.main`.
+pub fn configured_main(db: &ProjectDatabase) -> Option {
+ let options = db.project().metadata(db).options();
+ let main = options.run.as_ref()?.main.as_ref()?;
+ Some((**main).clone())
+}
+
+/// The file `by run ` executes, or `None` when the name resolves to
+/// nothing the build stages.
+///
+/// A package runs as its `__main__`, which is what `runpy` does with one.
+pub fn module_file(db: &ProjectDatabase, module: &str) -> Option {
+ let name = ModuleName::new(module.trim())?;
+ let environment = db.project().program(db).resolver_environment(db);
+ let resolved = resolve_module_confident(db, environment, &name)?;
+ let resolved = if resolved.kind(db).is_package() {
+ let main = ModuleName::new(&format!("{name}.__main__"))?;
+ resolve_module_confident(db, environment, &main)?
+ } else {
+ resolved
+ };
+ let file = resolved.file(db)?;
+ is_staged(db, file).then_some(file)
+}
+
+/// The name `by run` is given to execute `file`, or `None` when no name runs it.
+///
+/// `None` for a file outside every module root the build stages, for a file
+/// whose name the resolver finds another file for first, and for a package's
+/// `__init__`, since running a package runs its `__main__` instead.
+pub fn module_name(db: &ProjectDatabase, file: File) -> Option {
+ if !is_staged(db, file) {
+ return None;
+ }
+ let program_file = db.program_file(file);
+ let module = file_to_module(db, program_file.resolver_file(db))?;
+ if module.kind(db).is_package() {
+ return None;
+ }
+ Some(module.name(db).to_string())
+}
+
+/// Whether `file` lies under a module root `by run` stages.
+fn is_staged(db: &ProjectDatabase, file: File) -> bool {
+ let Some(path) = file.path(db).as_system_path() else {
+ return false;
+ };
+ let root = Path::new(db.project().root(db).as_str());
+ module_roots(db, root)
+ .iter()
+ .any(|candidate| Path::new(path.as_str()).starts_with(candidate))
+}
diff --git a/crates/by_stage/src/runtime.rs b/crates/by_stage/src/runtime.rs
index 6bfc67ae2f..07d418fed3 100644
--- a/crates/by_stage/src/runtime.rs
+++ b/crates/by_stage/src/runtime.rs
@@ -29,7 +29,7 @@ use ruff_db::system::{System, SystemPath};
/// the copies a build writes
///
/// claimed for every module rather than only those that turned out to call a
-/// helper: `by restage` recomputes one module against a tree an earlier build
+/// helper: `by restage` recomputes modules against a tree an earlier build
/// wrote, and an edit that newly reaches for a helper has to find the copy there
#[derive(Debug, Default)]
pub struct RuntimeLayout {
diff --git a/crates/by_stage/src/sourcemap.rs b/crates/by_stage/src/sourcemap.rs
index d6be88e6db..56f2f5817e 100644
--- a/crates/by_stage/src/sourcemap.rs
+++ b/crates/by_stage/src/sourcemap.rs
@@ -62,7 +62,7 @@ pub fn stage_module(
/// The same description [`stage_module`] produces, for a caller that is not going
/// to write the file itself.
///
-/// The language server's single-file re-stage is that caller: it returns the bytes
+/// The language server's re-stage of an edit is that caller: it returns the bytes
/// and lets the plugin write them, because only the plugin can roll the write back
/// together with the debugger request that follows it. Its `py_path` is filled in
/// by the caller, which is the one thing a description without a staging cannot
diff --git a/crates/by_transforms/src/lib.rs b/crates/by_transforms/src/lib.rs
index 62b2f4bac4..d2fc27fe9a 100644
--- a/crates/by_transforms/src/lib.rs
+++ b/crates/by_transforms/src/lib.rs
@@ -7,6 +7,14 @@ pub(crate) mod type_info;
pub use config::{Config, FloatLiteralLowering, PythonVersion, SoundnessPositions};
+/// A module's `main` as the program's command line — the reading the entry-point
+/// guard and its argument parser are generated from.
+pub mod entry_point {
+ pub use crate::transforms::main_function::{
+ Choice, CliSpelling, EntryParameter, EntryPoint, ParameterKind, entry_point, main_guards,
+ };
+}
+
use std::collections::{BTreeSet, HashSet};
use ruff_db::files::{File, system_path_to_file};
diff --git a/crates/by_transforms/src/transforms/annotation.rs b/crates/by_transforms/src/transforms/annotation.rs
index f1096ad712..fc124c3a07 100644
--- a/crates/by_transforms/src/transforms/annotation.rs
+++ b/crates/by_transforms/src/transforms/annotation.rs
@@ -2,7 +2,7 @@ use std::cell::{Cell, RefCell};
use ruff_diagnostics::{Edit, Fix};
use ruff_python_ast::{Expr, PythonVersion, Stmt};
-use ruff_text_size::Ranged;
+use ruff_text_size::{Ranged, TextRange};
use crate::Config;
use crate::config::FloatLiteralLowering;
@@ -51,6 +51,10 @@ pub(crate) struct TupleLiteralType<'src> {
/// dedicated passes' edits inside it are dropped — without this an element
/// would reach the output as surface syntax
leaves: RefCell>,
+ /// the operations `symbolic_type_op` folded, with their rendered types. our
+ /// replacement covers a folded operation nested inside a tuple type, so it
+ /// has to carry the fold rather than re-emit the operation
+ symbolic_substitutions: &'src [(TextRange, String)],
edits: Vec,
}
@@ -60,13 +64,19 @@ impl<'src> TupleLiteralType<'src> {
types: &'src dyn TypeInfo,
min_version: PythonVersion,
float_literals: FloatLiteralLowering,
+ symbolic_substitutions: &'src [(TextRange, String)],
) -> Self {
+ let mut leaves = CallableSyntax::new(source, float_literals).with_types(types);
+ for (range, rendered) in symbolic_substitutions {
+ leaves.add_substitution(*range, rendered.clone());
+ }
Self {
source,
types,
min_version,
needs_unpack_import: Cell::new(false),
- leaves: RefCell::new(CallableSyntax::new(source, float_literals).with_types(types)),
+ leaves: RefCell::new(leaves),
+ symbolic_substitutions,
edits: Vec::new(),
}
}
@@ -147,14 +157,25 @@ impl<'src> TupleLiteralType<'src> {
Some(format!("tuple[{}]", lowered.join(", ")))
}
- // `A | B` — propagate into both arms
+ // `(int, str) * n` — an operation `symbolic_type_op` folded is the type it
+ // folded to
+ Expr::BinOp(b)
+ if let Some((_, rendered)) = self
+ .symbolic_substitutions
+ .iter()
+ .find(|(range, _)| *range == b.range()) =>
+ {
+ Some(rendered.clone())
+ }
+
+ // `A | B` — propagate into both operands
Expr::BinOp(b) => {
let left = self.transform_annotation(&b.left);
let right = self.transform_annotation(&b.right);
if left.is_some() || right.is_some() {
let l = left.unwrap_or_else(|| self.fallback_src(&b.left));
let r = right.unwrap_or_else(|| self.fallback_src(&b.right));
- Some(format!("{l} | {r}"))
+ Some(format!("{l} {} {r}", b.op.as_str()))
} else {
None
}
@@ -333,6 +354,12 @@ impl TypeExprVisitor for TupleLiteralType<'_> {
{
return Recurse::Stop;
}
+ // an unpacked annotation, `*args: *(int, str)`, keeps its star: `unpack` lowers that
+ // to python's spelling on its own. rewriting the whole expression here would write over
+ // that edit, so the type inside the star is rewritten on its own range
+ if matches!(expr, Expr::Starred(_)) {
+ return Recurse::Descend;
+ }
// `transform_annotation` is a deep recursive rewriter that produces
// a single replacement string for the whole expression. emit the
// edit at the expression's range and tell the walker to stop —
@@ -360,11 +387,13 @@ impl<'src> TupleLiteralTypePass<'src> {
impl TypeAwarePass for TupleLiteralTypePass<'_> {
fn run(&self, stmts: &[Stmt], types: &dyn TypeInfo, ctx: &mut PassContext) {
+ let symbolic_substitutions = ctx.symbolic_substitutions.clone();
let mut inner = TupleLiteralType::new(
self.source,
types,
self.config.min_version,
self.config.float_literals,
+ &symbolic_substitutions,
);
walk_type_positions(stmts, Some(types), &mut inner);
for fix in inner.edits {
diff --git a/crates/by_transforms/src/transforms/main_function.rs b/crates/by_transforms/src/transforms/main_function.rs
index da860039d2..95ef058ef9 100644
--- a/crates/by_transforms/src/transforms/main_function.rs
+++ b/crates/by_transforms/src/transforms/main_function.rs
@@ -15,7 +15,9 @@
//! existing `__main__` guard or a bare top-level `main()` call — so the entry
//! point never runs twice
-use ruff_python_ast::{self as ast, CmpOp, Expr, ModModule, Parameters, Stmt, StmtFunctionDef};
+use ruff_python_ast::{
+ self as ast, CmpOp, Expr, ModModule, ParameterWithDefault, Parameters, Stmt, StmtFunctionDef,
+};
use super::ast_driver::{AstPass, PassContext};
use super::source_util::{is_synthetic_decorator, python_string_literal};
@@ -28,16 +30,6 @@ impl<'src> MainFunction<'src> {
pub(crate) fn new(source: &'src str) -> Self {
Self { source }
}
-
- /// true when `main` carries the synthetic `private` modifier, which the
- /// modifiers pass renames to `_main` — so it is not a public entry point
- /// and a synthesised `main()` call would dangle
- fn is_private(&self, func: &StmtFunctionDef) -> bool {
- func.decorator_list.iter().any(|dec| {
- is_synthetic_decorator(self.source, dec)
- && matches!(&dec.expression, Expr::Name(name) if name.id.as_str() == "private")
- })
- }
}
impl AstPass for MainFunction<'_> {
@@ -48,31 +40,27 @@ impl AstPass for MainFunction<'_> {
}
fn run(&self, module: &mut ModModule, ctx: &mut PassContext) {
- let Some(main) = last_top_level_main(&module.body) else {
+ let Some(entry) = entry_point(&module.body, self.source) else {
return;
};
- if self.is_private(main) {
- return;
- }
- let Some(params) = cli_params(&main.parameters) else {
- return;
- };
- // respect a hand-written entry point; never invoke `main` twice
- if module_invokes_main(&module.body) {
+ // a `private main` is renamed, a `main` the command line cannot fill
+ // cannot be called, and a module that invokes `main` itself keeps its
+ // own entry point — `main` never runs twice
+ if !entry.generates_guard() {
return;
}
ctx.epilogue.push("if __name__ == \"__main__\":".to_owned());
- let extra = extra_arguments_converter(&main.parameters);
- let call = if params.is_empty() && extra.is_none() {
+ let spec: Vec = entry.exposed().map(EntryParameter::spec_entry).collect();
+ let extra = entry.extra_arguments;
+ let call = if spec.is_empty() && extra.is_none() {
"main()".to_owned()
} else {
ctx.runtime.insert(crate::runtime::MAIN_ARGS);
ctx.epilogue
.push(" _by_args, _by_kwargs = _by_main_args(main, [".to_owned());
- for param in ¶ms {
- ctx.epilogue
- .push(format!(" {},", param.spec_entry()));
+ for entry in spec {
+ ctx.epilogue.push(format!(" {entry},"));
}
let close = match extra {
Some(converter) => format!(" ], {converter})"),
@@ -81,7 +69,7 @@ impl AstPass for MainFunction<'_> {
ctx.epilogue.push(close);
"main(*_by_args, **_by_kwargs)".to_owned()
};
- if main.is_async {
+ if entry.function.is_async {
ctx.epilogue.push(format!(" asyncio.run({call})"));
ctx.required_imports.push("import asyncio".to_owned());
} else {
@@ -90,77 +78,242 @@ impl AstPass for MainFunction<'_> {
}
}
-/// how a `main` parameter is spelled on the command line
-enum CliType {
- /// takes a value, converted by the named callable
- Value(&'static str),
- /// a `--name` / `--no-name` flag pair
- Flag,
+/// A module's top-level `main`, and what the transpiler makes of it as the
+/// program's command line.
+///
+/// This is the one reading of `main` there is: the pass above generates the
+/// guard and the argument parser from it, and anything else that needs to know
+/// a program's command line — an editor offering to fill in its arguments —
+/// asks [`entry_point`] rather than reading the signature a second way.
+pub struct EntryPoint<'a> {
+ /// the last top-level `def main` / `async def main`
+ pub function: &'a StmtFunctionDef,
+ /// `private def main` is renamed to `_main`, so it is no entry point and a
+ /// synthesised `main()` call would dangle
+ pub is_private: bool,
+ /// the module already invokes `main` itself — a `__main__` guard or a bare
+ /// top-level `main(...)` call
+ pub module_invokes_main: bool,
+ /// every parameter that is not variadic, in declared order
+ pub parameters: Vec>,
+ /// the converter for the arguments the interface does not claim, when a
+ /// leading `*rest` asks for them — see `extra_arguments_converter`
+ pub extra_arguments: Option<&'static str>,
+}
+
+impl EntryPoint<'_> {
+ /// The first required parameter the command line cannot supply. Invoking
+ /// `main` would raise `TypeError`, so such a `main` is no entry point.
+ pub fn blocked_by(&self) -> Option<&EntryParameter<'_>> {
+ self.parameters
+ .iter()
+ .find(|param| param.is_required() && param.spelling.is_none())
+ }
+
+ /// Whether the transpiler appends the `__main__` guard that runs `main`.
+ pub fn generates_guard(&self) -> bool {
+ !self.is_private && !self.module_invokes_main && self.blocked_by().is_none()
+ }
+
+ /// The parameters the generated command-line interface fills.
+ fn exposed(&self) -> impl Iterator- > {
+ self.parameters
+ .iter()
+ .filter(|param| param.spelling.is_some())
+ }
+
+ /// `main`'s docstring — the generated parser's `--help` description, which
+ /// it reads from `main.__doc__`.
+ pub fn docstring(&self) -> Option<&str> {
+ let Stmt::Expr(expr) = self.function.body.first()? else {
+ return None;
+ };
+ let Expr::StringLiteral(string) = &*expr.value else {
+ return None;
+ };
+ Some(string.value.to_str())
+ }
}
-/// a `main` parameter the generated command-line interface fills
-struct CliParam {
- name: String,
- ty: CliType,
- /// how the parameter can be passed to `main` itself: `positional`
- /// (positional-only), `keyword` (keyword-only), or `any`
- kind: &'static str,
- required: bool,
- /// the values the annotation admits, rendered as python literals, when it
- /// is a literal union — argparse rejects anything else before `main` runs
- choices: Option>,
+/// How a `main` parameter can be passed to `main` itself.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ParameterKind {
+ /// positional-only: always handed over positionally
+ Positional,
+ /// either way
+ Any,
+ /// keyword-only: takes no positional slot
+ Keyword,
}
-impl CliParam {
+impl ParameterKind {
+ /// the spelling the `_by_main_args` spec carries
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Positional => "positional",
+ Self::Any => "any",
+ Self::Keyword => "keyword",
+ }
+ }
+}
+
+/// One non-variadic parameter of `main`.
+pub struct EntryParameter<'a> {
+ pub parameter: &'a ParameterWithDefault,
+ pub kind: ParameterKind,
+ /// how the command line spells it; `None` when the annotation has no
+ /// command-line spelling, so the parameter is not exposed
+ pub spelling: Option,
+}
+
+impl EntryParameter<'_> {
+ pub fn name(&self) -> &str {
+ self.parameter.parameter.name.as_str()
+ }
+
+ pub fn is_required(&self) -> bool {
+ self.parameter.default.is_none()
+ }
+
+ /// The option spellings `_by_main_args` registers for this parameter, dash
+ /// form first; a name with an underscore also answers to it as written.
+ pub fn flags(&self) -> Vec {
+ let name = self.name();
+ let mut flags = vec![format!("--{}", name.replace('_', "-"))];
+ if name.contains('_') {
+ flags.push(format!("--{name}"));
+ }
+ flags
+ }
+
+ /// The `--no-…` spellings `_by_main_args` registers to set a flag false;
+ /// empty for a parameter that takes a value.
+ pub fn negative_flags(&self) -> Vec {
+ if !matches!(
+ self.spelling,
+ Some(CliSpelling {
+ converter: None,
+ ..
+ })
+ ) {
+ return Vec::new();
+ }
+ self.flags()
+ .iter()
+ .map(|flag| format!("--no-{}", &flag[2..]))
+ .collect()
+ }
+
/// the `(name, converter, kind, required, choices)` tuple `_by_main_args`
- /// consumes
+ /// consumes; only an exposed parameter has one
fn spec_entry(&self) -> String {
- let converter = match &self.ty {
- CliType::Value(callable) => (*callable).to_owned(),
- CliType::Flag => "None".to_owned(),
+ let (converter, choices) = match &self.spelling {
+ Some(spelling) => (spelling.converter.unwrap_or("None"), &spelling.choices),
+ None => ("None", &None),
};
- let required = if self.required { "True" } else { "False" };
- let name = &self.name;
- let kind = self.kind;
- let choices = match &self.choices {
- Some(values) => format!("({},)", values.join(", ")),
+ let required = if self.is_required() { "True" } else { "False" };
+ let name = self.name();
+ let kind = self.kind.as_str();
+ let choices = match choices {
+ Some(values) => format!(
+ "({},)",
+ values
+ .iter()
+ .map(|choice| choice.literal.as_str())
+ .collect::>()
+ .join(", ")
+ ),
None => "None".to_owned(),
};
format!("(\"{name}\", {converter}, \"{kind}\", {required}, {choices})")
}
}
-/// The parameters of `main` that the command line fills, or `None` when the
-/// signature has a required parameter the command line can't supply — such a
-/// `main` isn't an entry point, because invoking it would raise `TypeError`.
+/// How a `main` parameter is spelled on the command line.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CliSpelling {
+ /// the callable that converts the argument, as the source names it; `None`
+ /// for a `--name` / `--no-name` flag pair, which takes no value
+ pub converter: Option<&'static str>,
+ /// the values the annotation admits, when it is a literal union — argparse
+ /// rejects anything else before `main` runs
+ pub choices: Option>,
+}
+
+/// One value a literal-union parameter admits.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Choice {
+ /// the value as a python literal, as the parser's `choices` holds it
+ pub literal: String,
+ /// the value as it is written on the command line
+ pub value: String,
+}
+
+/// The module's entry point, or `None` when it has no top-level `main`.
///
-/// A parameter whose annotation has no command-line spelling is skipped rather
-/// than exposed, so it keeps its default. Variadics are skipped for the same
-/// reason: they never require an argument.
-fn cli_params(params: &Parameters) -> Option> {
+/// `source` is the text `body` was parsed from; the `private` modifier is read
+/// against it.
+pub fn entry_point<'a>(body: &'a [Stmt], source: &str) -> Option> {
+ let function = last_top_level_main(body)?;
+ Some(EntryPoint {
+ function,
+ is_private: is_private(source, function),
+ module_invokes_main: module_invokes_main(body),
+ parameters: parameters(&function.parameters),
+ extra_arguments: extra_arguments_converter(&function.parameters),
+ })
+}
+
+/// true when `main` carries the synthetic `private` modifier, which the
+/// modifiers pass renames to `_main`
+fn is_private(source: &str, func: &StmtFunctionDef) -> bool {
+ func.decorator_list.iter().any(|dec| {
+ is_synthetic_decorator(source, dec)
+ && matches!(&dec.expression, Expr::Name(name) if name.id.as_str() == "private")
+ })
+}
+
+/// Every non-variadic parameter of `main`, with its command-line spelling.
+///
+/// A parameter whose annotation has no command-line spelling is not exposed,
+/// so it keeps its default — or, when it has none, stops `main` being an entry
+/// point ([`EntryPoint::blocked_by`]). Variadics never require an argument, so
+/// they are left out.
+fn parameters(params: &Parameters) -> Vec> {
let groups = [
- (¶ms.posonlyargs, "positional"),
- (¶ms.args, "any"),
- (¶ms.kwonlyargs, "keyword"),
+ (¶ms.posonlyargs, ParameterKind::Positional),
+ (¶ms.args, ParameterKind::Any),
+ (¶ms.kwonlyargs, ParameterKind::Keyword),
];
- let mut cli = Vec::new();
- for (group, kind) in groups {
- for param in group {
- let required = param.default.is_none();
- match param.parameter.annotation.as_deref().and_then(cli_type) {
- Some((ty, choices)) => cli.push(CliParam {
- name: param.parameter.name.to_string(),
- ty,
- kind,
- required,
- choices,
- }),
- None if required => return None,
- None => {}
- }
- }
- }
- Some(cli)
+ groups
+ .into_iter()
+ .flat_map(|(group, kind)| {
+ group.iter().map(move |parameter| EntryParameter {
+ parameter,
+ kind,
+ spelling: parameter
+ .parameter
+ .annotation
+ .as_deref()
+ .and_then(cli_type)
+ .map(|(ty, choices)| CliSpelling {
+ converter: match ty {
+ CliType::Value(callable) => Some(callable),
+ CliType::Flag => None,
+ },
+ choices,
+ }),
+ })
+ })
+ .collect()
+}
+
+/// how a `main` parameter is spelled on the command line
+enum CliType {
+ /// takes a value, converted by the named callable
+ Value(&'static str),
+ /// a `--name` / `--no-name` flag pair
+ Flag,
}
/// The command-line spelling of an annotation — its converter and, for a
@@ -169,7 +322,7 @@ fn cli_params(params: &Parameters) -> Option> {
/// Matched on the annotation as written: the converter emitted into the spec
/// is the same name the source used, so it resolves to whatever that name is
/// bound to at runtime.
-fn cli_type(annotation: &Expr) -> Option<(CliType, Option>)> {
+fn cli_type(annotation: &Expr) -> Option<(CliType, Option>)> {
match annotation {
Expr::Name(name) => {
let ty = match name.id.as_str() {
@@ -193,7 +346,7 @@ fn cli_type(annotation: &Expr) -> Option<(CliType, Option>)> {
// a union: either `T | None`, which is `T?` written out, or a union of
// literals, which argparse expresses as `choices`
Expr::BinOp(bin_op) if matches!(bin_op.op, ast::Operator::BitOr) => {
- let mut named: Option<(CliType, Option>)> = None;
+ let mut named: Option<(CliType, Option>)> = None;
let mut literals = Vec::new();
let mut literal_converter: Option<&'static str> = None;
for operand in union_operands(annotation) {
@@ -311,12 +464,27 @@ fn is_none_literal(expr: &Expr) -> bool {
expr.is_none_literal_expr()
}
-/// a literal the command line can carry, as `(converter, python literal)`
-fn literal_choice(expr: &Expr) -> Option<(&'static str, String)> {
+/// a literal the command line can carry, as its converter and the value
+fn literal_choice(expr: &Expr) -> Option<(&'static str, Choice)> {
match expr {
- Expr::StringLiteral(string) => Some(("str", python_string_literal(string.value.to_str()))),
+ Expr::StringLiteral(string) => {
+ let value = string.value.to_str();
+ Some((
+ "str",
+ Choice {
+ literal: python_string_literal(value),
+ value: value.to_owned(),
+ },
+ ))
+ }
Expr::NumberLiteral(number) => match &number.value {
- ast::Number::Int(int) => Some(("int", int.to_string())),
+ ast::Number::Int(int) => Some((
+ "int",
+ Choice {
+ literal: int.to_string(),
+ value: int.to_string(),
+ },
+ )),
_ => None,
},
_ => None,
@@ -343,6 +511,15 @@ fn module_invokes_main(body: &[Stmt]) -> bool {
})
}
+/// The module's own top-level `if __name__ == "__main__":` statements — each an
+/// entry point written by hand, whatever the module's `main` is.
+pub fn main_guards(body: &[Stmt]) -> impl Iterator
- {
+ body.iter().filter_map(|stmt| match stmt {
+ Stmt::If(if_stmt) if is_dunder_main_guard(&if_stmt.test) => Some(if_stmt),
+ _ => None,
+ })
+}
+
/// matches `__name__ == "__main__"`, accepting either operand order
fn is_dunder_main_guard(test: &Expr) -> bool {
let Expr::Compare(cmp) = test else {
@@ -789,6 +966,74 @@ mod tests {
"});
}
+ fn parsed(source: &str) -> ruff_python_parser::Parsed {
+ ruff_python_parser::parse_unchecked_source(
+ source,
+ ruff_python_ast::PySourceType::BasedPython,
+ )
+ }
+
+ /// the reading an editor asks for is the one the guard is generated from, so
+ /// a generic `main` is still `main`, and its parameters are still its own
+ #[test]
+ fn a_generic_main_is_the_entry_point() {
+ let source = "def main[T](name: str, count: int = 1):\n pass\n";
+ assert!(guard(source).contains("(\"name\", str, \"any\", True, None),"));
+ let module = parsed(source);
+ let entry = super::entry_point(&module.syntax().body, source).expect("a main");
+ assert!(entry.generates_guard());
+ let names: Vec<&str> = entry
+ .parameters
+ .iter()
+ .map(super::EntryParameter::name)
+ .collect();
+ assert_eq!(names, ["name", "count"]);
+ }
+
+ #[test]
+ fn a_main_the_command_line_cannot_fill_names_what_blocks_it() {
+ let source = "def main(a: int, argv, b: str = \"x\"):\n \"\"\"Adds.\"\"\"\n";
+ let module = parsed(source);
+ let entry = super::entry_point(&module.syntax().body, source).expect("a main");
+ assert_eq!(
+ entry.blocked_by().map(super::EntryParameter::name),
+ Some("argv")
+ );
+ assert!(!entry.generates_guard());
+ assert_eq!(entry.docstring(), Some("Adds."));
+ }
+
+ #[test]
+ fn a_main_call_inside_a_docstring_is_not_an_invocation() {
+ let source = "\"\"\"\nmain()\n\"\"\"\ndef main():\n pass\n";
+ let module = parsed(source);
+ let entry = super::entry_point(&module.syntax().body, source).expect("a main");
+ assert!(!entry.module_invokes_main);
+ assert!(guard(source).contains(" main()"));
+ }
+
+ #[test]
+ fn flags_are_the_spellings_the_runtime_registers() {
+ let source = "def main(out_dir: Path, dry_run: bool = False, mode: \"a\" | \"b\" = \"a\"):\n pass\n";
+ let module = parsed(source);
+ let entry = super::entry_point(&module.syntax().body, source).expect("a main");
+ let [out_dir, dry_run, mode] = entry.parameters.as_slice() else {
+ panic!("three parameters");
+ };
+ assert_eq!(out_dir.flags(), ["--out-dir", "--out_dir"]);
+ assert!(out_dir.negative_flags().is_empty());
+ assert_eq!(dry_run.negative_flags(), ["--no-dry-run", "--no-dry_run"]);
+ let choices: Vec<&str> = mode
+ .spelling
+ .as_ref()
+ .and_then(|spelling| spelling.choices.as_ref())
+ .expect("choices")
+ .iter()
+ .map(|choice| choice.value.as_str())
+ .collect();
+ assert_eq!(choices, ["a", "b"]);
+ }
+
/// a stub is never run as a script, so it declares `main` and nothing calls it
#[test]
fn a_stub_gets_no_entry_point() {
diff --git a/crates/by_transforms/src/transforms/symbolic_type_op.rs b/crates/by_transforms/src/transforms/symbolic_type_op.rs
index 4d35e7ad13..96f515fa5c 100644
--- a/crates/by_transforms/src/transforms/symbolic_type_op.rs
+++ b/crates/by_transforms/src/transforms/symbolic_type_op.rs
@@ -29,7 +29,9 @@ use ruff_python_parser::parse_expression;
use ruff_text_size::{Ranged, TextRange};
use super::ast_driver::{AstPass, PassContext};
-use super::type_expr_walker::{Recurse, TypeExprVisitor, TypePos, walk_type_positions};
+use super::type_expr_walker::{
+ Recurse, TypeExprVisitor, TypePos, walk_one_type_expr, walk_type_positions,
+};
use crate::type_info::TypeInfo;
/// One resolved operation: the replacement node (spliced into the working AST)
@@ -140,6 +142,19 @@ impl TypeExprVisitor for FoldCollector<'_> {
// type parameter's bound — python cannot express the dependency on `T`, and
// the bound's member type is the guarantee every specialization satisfies
Expr::Attribute(_) => self.types.is_attribute_type(expr),
+ // the shared walker stops at a tuple type, which `annotation` lowers as a
+ // whole, carrying the folds found inside it: `(str, *(int, str) * n)`
+ Expr::Tuple(tuple) if !tuple.is_anon_named_tuple => {
+ for element in &tuple.elts {
+ let element = match element {
+ Expr::Starred(starred) => starred.value.as_ref(),
+ Expr::Named(named) => named.value.as_ref(),
+ element => element,
+ };
+ walk_one_type_expr(element, self);
+ }
+ return Recurse::Stop;
+ }
_ => false,
};
if !foldable {
@@ -502,6 +517,32 @@ mod tests {
);
}
+ #[test]
+ fn tuple_repetition() {
+ // python has no spelling for a tuple whose elements repeat in order, so the
+ // annotation keeps only which types its elements can be
+ check("x: (int, str) * int\n", "x: tuple[int | str, ...]\n");
+ }
+
+ #[test]
+ fn tuple_repetition_nested_in_subscript() {
+ check(
+ "x: list[(int, str) * int]\ny: dict[str, (int, str) * 2]\n",
+ "x: list[tuple[int | str, ...]]\ny: dict[str, tuple[int, str, int, str]]\n",
+ );
+ }
+
+ #[test]
+ fn tuple_repetition_unpacked_in_tuple_type() {
+ check(
+ "x: (bytes, *(int, str) * int, bytes)\n",
+ indoc! {"
+ from typing_extensions import Unpack
+ x: tuple[bytes, Unpack[tuple[int | str, ...]], bytes]
+ "},
+ );
+ }
+
#[test]
fn typeof_operand_nested_in_subscript() {
// the fold consumes the `typeof` operand even inside a subscript slice,
diff --git a/crates/by_transforms/src/transforms/type_expr_walker.rs b/crates/by_transforms/src/transforms/type_expr_walker.rs
index de6c993228..fa92e88d41 100644
--- a/crates/by_transforms/src/transforms/type_expr_walker.rs
+++ b/crates/by_transforms/src/transforms/type_expr_walker.rs
@@ -189,6 +189,13 @@ impl TypePosWalker<'_> {
// edit (leaving the narrow transform's `needs_import` flag set
// even though the edit was dropped)
Expr::Tuple(_) => {}
+ // basedpython: the annotation of a variadic parameter, `*args: *(int, str)`, and an
+ // unpacked element of a generic argument list, `tuple[*Ts]`. the star itself is
+ // lowered by `unpack`, whose edit covers the star alone, so the type inside it is a
+ // type position like any other
+ Expr::Starred(s) => {
+ self.visit_type_expr(&s.value, TypePos::Nested);
+ }
// an inline protocol is hoisted to a synthesized class by
// `protocol_type`, which renders every member type through its own
// lowerer. descending would let sibling transforms emit narrow
diff --git a/crates/by_transforms/src/transforms/unpack.rs b/crates/by_transforms/src/transforms/unpack.rs
index aea9e73b3f..ec25f0ddb2 100644
--- a/crates/by_transforms/src/transforms/unpack.rs
+++ b/crates/by_transforms/src/transforms/unpack.rs
@@ -16,7 +16,7 @@ use ruff_python_ast::PythonVersion;
use ruff_python_ast::helpers::top_star_slice_elements;
use ruff_python_ast::visitor::{Visitor, walk_expr, walk_stmt};
use ruff_python_ast::{Expr, ModModule, Stmt};
-use ruff_text_size::{Ranged, TextRange};
+use ruff_text_size::{Ranged, TextRange, TextSize};
use super::ast_driver::{AstPass, PassContext};
use crate::config::Config;
@@ -131,6 +131,15 @@ impl<'ast> Visitor<'ast> for PackForwarding {
}
}
+/// The `*` of an unpack, which is what [`Unpack`] replaces.
+///
+/// Only the star itself: the type after it can be parenthesized, `*((int, str) * n)`, and a
+/// range reaching to the start of that type would take the opening parenthesis with it, and
+/// collide with the edit that lowers the type inside it.
+fn star_token_range(unpack: TextRange) -> TextRange {
+ TextRange::at(unpack.start(), TextSize::from(1))
+}
+
struct State {
edits: RefCell>,
needs_import: bool,
@@ -140,7 +149,7 @@ struct State {
impl State {
fn rewrite_subscript_starred(&mut self, starred: &ruff_python_ast::ExprStarred) {
self.needs_import = true;
- let star_range = TextRange::new(starred.range().start(), starred.value.range().start());
+ let star_range = star_token_range(starred.range());
self.edits
.borrow_mut()
.push((star_range, "Unpack[".to_owned()));
@@ -159,7 +168,7 @@ impl State {
return;
}
self.needs_import = true;
- let star_range = TextRange::new(ann.range().start(), starred.value.range().start());
+ let star_range = star_token_range(ann.range());
self.edits
.borrow_mut()
.push((star_range, "Unpack[".to_owned()));
@@ -216,6 +225,17 @@ mod tests {
);
}
+ fn check_py311(input: &str, expected: &str) {
+ let config = Config {
+ min_version: PythonVersion::PY311,
+ ..Config::test_default()
+ };
+ assert_eq!(
+ transpile(input, &config).unwrap(),
+ crate::python_passthrough::lazify_expected(expected)
+ );
+ }
+
#[test]
fn rewrites_starred_vararg_annotation() {
check(
@@ -227,6 +247,29 @@ mod tests {
);
}
+ /// a tuple type inside an unpacked annotation is lowered like any other type position. the
+ /// `Unpack[` replacement covers the star alone, so it does not collide with the lowering of a
+ /// parenthesized type after it
+ #[test]
+ fn rewrites_tuple_type_inside_starred_vararg_annotation() {
+ check(
+ "def f(*args: *(int, str)): ...\ndef g(*args: *((int, str) * int)): ...\n",
+ indoc! {"
+ from typing_extensions import Unpack
+ def f(*args: Unpack[tuple[int, str]]): ...
+ def g(*args: Unpack[(tuple[int | str, ...])]): ...
+ "},
+ );
+ }
+
+ #[test]
+ fn starred_vararg_annotation_keeps_the_star_on_py311() {
+ check_py311(
+ "def f(*args: *((int, str) * 2)): ...\n",
+ "def f(*args: *(tuple[int, str, int, str])): ...\n",
+ );
+ }
+
/// a keyword pack is a `ParamSpec` at runtime, so `**kwargs: **Kwargs` takes its `.kwargs`
/// spelling. the pep695 polyfill's rename must still reach the pack's name
#[test]
diff --git a/crates/ruff_python_ast/src/generated_names.rs b/crates/ruff_python_ast/src/generated_names.rs
new file mode 100644
index 0000000000..f990af1db3
--- /dev/null
+++ b/crates/ruff_python_ast/src/generated_names.rs
@@ -0,0 +1,78 @@
+//! basedpython: the name nodes the parser writes rather than the author
+//!
+//! several constructs stand for code nobody typed. `init(let x: int)` stands for
+//! a `self.x = x`, a property accessor's `field` stands for a read of the
+//! property's backing storage, and an enum variant's anonymous field is declared
+//! under a name the parser counts out (`_0`, `_1`, …). the nodes built for those
+//! carry a source range all the same — a diagnostic about one has to point
+//! somewhere the reader can see — so a range is not enough to tell them apart
+//! from what the author wrote
+//!
+//! the parser records each one here instead, and a consumer that cares whether a
+//! name in the tree is the author's asks [`GeneratedNames`] rather than reading
+//! the source back. `used-underscore-name` is the consumer this exists for: it
+//! reports a use of a name the author spelled with a leading underscore, and both
+//! halves of that question — is this use one the author wrote, and is the
+//! declaration it resolves to spelled by the author — are answered here
+
+use ruff_text_size::TextRange;
+use rustc_hash::FxHashMap;
+
+use crate::name::Name;
+
+/// whose name a generated node carries
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+#[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
+pub enum GeneratedName {
+ /// the parser wrote the node, under a name the author wrote elsewhere: the
+ /// `self.x = x` of `init(let x: int)` names the parameter `x`
+ AuthorName,
+ /// the parser wrote the node *and* the name it carries: a property's backing
+ /// storage `__age`, an enum variant's anonymous field `_0`
+ ParserName,
+}
+
+/// every name node the parser wrote in one file, by the range and name it carries
+///
+/// two generated nodes can share a range — an `init` parameter's `self.x` target
+/// and the `x` it reads both sit on the parameter — so the name is part of the
+/// key. a node the author wrote is never recorded, and looking one up answers
+/// `None`
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct GeneratedNames(FxHashMap<(TextRange, Name), GeneratedName>);
+
+impl GeneratedNames {
+ pub fn insert(&mut self, range: TextRange, name: Name, generated: GeneratedName) {
+ self.0.insert((range, name), generated);
+ }
+
+ pub fn extend(&mut self, other: Self) {
+ self.0.extend(other.0);
+ }
+
+ /// what wrote the name `name` at `range`, or `None` where the author did
+ pub fn get(&self, range: TextRange, name: Name) -> Option {
+ self.0.get(&(range, name)).copied()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ pub fn shrink_to_fit(&mut self) {
+ self.0.shrink_to_fit();
+ }
+}
+
+#[cfg(feature = "get-size")]
+impl get_size2::GetSize for GeneratedNames {
+ fn get_heap_size(&self) -> usize {
+ let entry = size_of::<((TextRange, Name), GeneratedName)>();
+ self.0.capacity() * entry
+ + self
+ .0
+ .keys()
+ .map(|(_, name)| get_size2::GetSize::get_heap_size(name))
+ .sum::()
+ }
+}
diff --git a/crates/ruff_python_ast/src/lib.rs b/crates/ruff_python_ast/src/lib.rs
index 03f1393d47..6f3637a1d0 100644
--- a/crates/ruff_python_ast/src/lib.rs
+++ b/crates/ruff_python_ast/src/lib.rs
@@ -14,6 +14,7 @@ pub mod docstrings;
mod expression;
pub mod find_node;
mod generated;
+pub mod generated_names;
pub mod helpers;
pub mod identifier;
mod int;
diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/tuple_repetition.by b/crates/ruff_python_formatter/resources/test/fixtures/ruff/tuple_repetition.by
new file mode 100644
index 0000000000..2e047f661d
--- /dev/null
+++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/tuple_repetition.by
@@ -0,0 +1,17 @@
+# a repeated tuple type is a tuple type multiplied by `int`, and fixed elements around
+# the repetitions unpack it into an outer tuple type
+
+
+def pairs(n: int) -> (int, str) * int:
+ return (1, "a") * n
+
+
+framed: (bytes, *(int, str) * int, bytes)
+framed_loose: (bytes,*( int,str )*int,bytes)
+nested: list[(int, str)*int]
+
+type Pairs = (int, str) * int
+
+
+def variadic(*args: *((int, str) * int)) -> None:
+ pass
diff --git a/crates/ruff_python_formatter/tests/snapshots/format@tuple_repetition.by.snap b/crates/ruff_python_formatter/tests/snapshots/format@tuple_repetition.by.snap
new file mode 100644
index 0000000000..1e61ec7710
--- /dev/null
+++ b/crates/ruff_python_formatter/tests/snapshots/format@tuple_repetition.by.snap
@@ -0,0 +1,45 @@
+---
+source: crates/ruff_python_formatter/tests/fixtures.rs
+input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/tuple_repetition.by
+---
+## Input
+```bython
+# a repeated tuple type is a tuple type multiplied by `int`, and fixed elements around
+# the repetitions unpack it into an outer tuple type
+
+
+def pairs(n: int) -> (int, str) * int:
+ return (1, "a") * n
+
+
+framed: (bytes, *(int, str) * int, bytes)
+framed_loose: (bytes,*( int,str )*int,bytes)
+nested: list[(int, str)*int]
+
+type Pairs = (int, str) * int
+
+
+def variadic(*args: *((int, str) * int)) -> None:
+ pass
+```
+
+## Output
+```bython
+# a repeated tuple type is a tuple type multiplied by `int`, and fixed elements around
+# the repetitions unpack it into an outer tuple type
+
+
+def pairs(n: int) -> (int, str) * int:
+ return (1, "a") * n
+
+
+framed: (bytes, *(int, str) * int, bytes)
+framed_loose: (bytes, *(int, str) * int, bytes)
+nested: list[(int, str) * int]
+
+type Pairs = (int, str) * int
+
+
+def variadic(*args: *((int, str) * int)) -> None:
+ pass
+```
diff --git a/crates/ruff_python_parser/src/lib.rs b/crates/ruff_python_parser/src/lib.rs
index 7732b25c1f..91a3219b2e 100644
--- a/crates/ruff_python_parser/src/lib.rs
+++ b/crates/ruff_python_parser/src/lib.rs
@@ -71,6 +71,7 @@ pub use crate::parser::ParseOptions;
use crate::parser::Parser;
+use ruff_python_ast::generated_names::GeneratedNames;
use ruff_python_ast::token::{Token, TokenFlags, TokenKind, Tokens};
use ruff_python_ast::{
AtomicNodeIndex, Expr, Mod, ModExpression, ModModule, PySourceType, StringFlags, StringLiteral,
@@ -320,6 +321,7 @@ pub fn parse_cells_unchecked(
let mut tokens = Vec::new();
let mut errors = Vec::new();
let mut unsupported_syntax_errors = Vec::new();
+ let mut generated_names = GeneratedNames::default();
let mut module_range: Option = None;
while let Some(range) = ranges.next() {
@@ -335,6 +337,7 @@ pub fn parse_cells_unchecked(
tokens: cell_tokens,
errors: cell_errors,
unsupported_syntax_errors: cell_unsupported_syntax_errors,
+ generated_names: cell_generated_names,
} = Parser::new_starts_at(cell_source, range.start(), options.clone())
.parse()
.try_into_module()
@@ -344,6 +347,7 @@ pub fn parse_cells_unchecked(
tokens.extend(cell_tokens);
errors.extend(cell_errors);
unsupported_syntax_errors.extend(cell_unsupported_syntax_errors);
+ generated_names.extend(cell_generated_names);
// Each range excludes its trailing `\n` separator (see the doc comment above), leaving a
// one-byte gap in the token stream. Cover it with a `NonLogicalNewline` so token-based
@@ -369,6 +373,7 @@ pub fn parse_cells_unchecked(
tokens.shrink_to_fit();
errors.shrink_to_fit();
unsupported_syntax_errors.shrink_to_fit();
+ generated_names.shrink_to_fit();
Parsed {
syntax: ModModule {
@@ -377,6 +382,7 @@ pub fn parse_cells_unchecked(
body,
},
tokens: Tokens::new(tokens),
+ generated_names,
errors,
unsupported_syntax_errors,
}
@@ -389,6 +395,7 @@ pub struct Parsed {
tokens: Tokens,
errors: Vec,
unsupported_syntax_errors: Vec,
+ generated_names: GeneratedNames,
}
impl Parsed {
@@ -412,6 +419,11 @@ impl Parsed {
&self.unsupported_syntax_errors
}
+ /// basedpython: the name nodes the parser wrote rather than the author
+ pub fn generated_names(&self) -> &GeneratedNames {
+ &self.generated_names
+ }
+
/// Consumes the [`Parsed`] output and returns the contained syntax node.
pub fn into_syntax(self) -> T {
self.syntax
@@ -498,6 +510,7 @@ impl Parsed {
tokens: self.tokens,
errors: self.errors,
unsupported_syntax_errors: self.unsupported_syntax_errors,
+ generated_names: self.generated_names,
}),
Mod::Expression(_) => None,
}
@@ -518,6 +531,7 @@ impl Parsed {
tokens: self.tokens,
errors: self.errors,
unsupported_syntax_errors: self.unsupported_syntax_errors,
+ generated_names: self.generated_names,
}),
}
}
diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs
index 8c562e80be..72074c35cf 100644
--- a/crates/ruff_python_parser/src/parser/mod.rs
+++ b/crates/ruff_python_parser/src/parser/mod.rs
@@ -3,6 +3,7 @@ use std::cmp::Ordering;
use std::str::FromStr;
use bitflags::bitflags;
+use ruff_python_ast::generated_names::{GeneratedName, GeneratedNames};
use ruff_python_ast::name::Name;
use ruff_python_ast::token::TokenKind;
use ruff_python_ast::{
@@ -117,6 +118,10 @@ pub(crate) struct Parser<'src> {
/// by `class_body_depth > 0`), empty everywhere else.
pending_members: Vec,
+ /// basedpython: the name nodes this parse wrote rather than the author —
+ /// see [`GeneratedNames`]
+ generated_names: GeneratedNames,
+
/// basedpython: `(property, backing field)` pairs declared in the class body
/// currently being parsed whose getter is a pure field read. In-class *reads*
/// of those properties are retargeted at the backing field once the body is
@@ -194,6 +199,7 @@ impl<'src> Parser<'src> {
current_token_id: TokenId::default(),
class_body_depth: 0,
function_body_depth: 0,
+ generated_names: GeneratedNames::default(),
pending_members: Vec::new(),
pending_narrow_props: Vec::new(),
expr_consumed_suite: false,
@@ -316,6 +322,7 @@ impl<'src> Parser<'src> {
tokens: Tokens::new(tokens),
errors: parse_errors,
unsupported_syntax_errors: self.unsupported_syntax_errors,
+ generated_names: self.generated_names,
};
}
@@ -348,9 +355,17 @@ impl<'src> Parser<'src> {
tokens: Tokens::new(tokens),
errors: merged,
unsupported_syntax_errors: self.unsupported_syntax_errors,
+ generated_names: self.generated_names,
}
}
+ /// basedpython: records that this parse, rather than the author, wrote the
+ /// name `name` at `range` — see [`GeneratedNames`] for why a range is not
+ /// enough to tell the two apart
+ fn record_generated_name(&mut self, range: TextRange, name: Name, generated: GeneratedName) {
+ self.generated_names.insert(range, name, generated);
+ }
+
/// Returns the start position for a node that starts at the current token.
fn node_start(&self) -> TextSize {
self.current_token_range().start()
diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs
index 7a7dab25dc..41effc25ce 100644
--- a/crates/ruff_python_parser/src/parser/statement.rs
+++ b/crates/ruff_python_parser/src/parser/statement.rs
@@ -2,6 +2,7 @@ use std::fmt::{Display, Write};
use thin_vec::ThinVec;
+use ruff_python_ast::generated_names::GeneratedName;
use ruff_python_ast::helpers::{
DeclarationMarker, DeclarationMarkerKind, MemberVisibility, declaration_annotation_type,
is_compound_statement, property_backing_name, written_annotation_type,
@@ -282,6 +283,10 @@ fn synthetic_variant_decorator(marker: &'static str, range: TextRange) -> ast::D
struct FieldRewriter {
backing: Name,
seen: std::cell::Cell,
+ /// where the rewrite wrote the backing name. the author wrote `field` at each
+ /// of these ranges, not `__x` — see
+ /// [`ruff_python_ast::generated_names::GeneratedNames`]
+ rewritten: std::cell::RefCell>,
}
/// Whether an accessor body is exactly a read of the backing field — the shape an
@@ -327,6 +332,10 @@ struct RetargetPropertyAccess<'a> {
properties: &'a [PropertyRetarget],
/// the enclosing method's first parameter, i.e. what `self` is called here
receiver: &'a str,
+ /// what each retarget wrote. the author wrote the property's own name at each
+ /// of these ranges, not the name it was pointed at — see
+ /// [`ruff_python_ast::generated_names::GeneratedNames`]
+ retargeted: std::cell::RefCell>,
}
impl ruff_python_ast::visitor::transformer::Transformer for RetargetPropertyAccess<'_> {
@@ -351,14 +360,21 @@ impl ruff_python_ast::visitor::transformer::Transformer for RetargetPropertyAcce
} else {
property.write.clone()
};
+ self.retargeted
+ .borrow_mut()
+ .push((attr.attr.range, attr.attr.id.clone()));
}
ruff_python_ast::visitor::transformer::walk_expr(self, expr);
}
}
/// Applies [`RetargetPropertyAccess`] to every method in a class body.
-fn narrow_property_reads(body: &mut [Stmt], properties: &[PropertyRetarget]) {
+fn narrow_property_reads(
+ body: &mut [Stmt],
+ properties: &[PropertyRetarget],
+) -> Vec<(TextRange, Name)> {
use ruff_python_ast::visitor::transformer::Transformer;
+ let mut retargeted = Vec::new();
for member in body {
let Stmt::FunctionDef(func) = member else {
continue;
@@ -377,11 +393,14 @@ fn narrow_property_reads(body: &mut [Stmt], properties: &[PropertyRetarget]) {
let rewriter = RetargetPropertyAccess {
properties,
receiver: receiver.as_str(),
+ retargeted: std::cell::RefCell::new(Vec::new()),
};
for stmt in &mut func.body {
rewriter.visit_stmt(stmt);
}
+ retargeted.extend(rewriter.retargeted.take());
}
+ retargeted
}
/// A zero-width synthetic parameter for a synthesised accessor signature.
@@ -454,6 +473,9 @@ fn build_property_fn(
/// An attribute access on the implicit `self`, used to reach a property's
/// backing storage (`self._`) from a synthesised accessor body.
+///
+/// The caller records the name at its range, because the author did not write it
+/// — see [`ruff_python_ast::generated_names::GeneratedNames`].
fn synth_backing_attr(backing: &Name, ctx: ExprContext, at: TextSize) -> Expr {
let range = TextRange::empty(at);
Expr::Attribute(ast::ExprAttribute {
@@ -532,6 +554,7 @@ impl ruff_python_ast::visitor::transformer::Transformer for FieldRewriter {
{
self.seen.set(true);
let range = name.range;
+ self.rewritten.borrow_mut().push(range);
let ctx = name.ctx;
*expr = Expr::Attribute(ast::ExprAttribute {
value: Box::new(Expr::Name(ast::ExprName {
@@ -2608,10 +2631,11 @@ impl<'src> Parser<'src> {
self.bump(TokenKind::Colon);
(ident.id, ident.range)
} else {
- (
- Name::from(format!("_{index}").as_str()),
- TextRange::empty(self.current_token_range().start()),
- )
+ // the parser counts the anonymous fields out; nobody wrote `_0`
+ let name = Name::from(format!("_{index}").as_str());
+ let range = TextRange::empty(self.current_token_range().start());
+ self.record_generated_name(range, name.clone(), GeneratedName::ParserName);
+ (name, range)
};
let annotation = self.parse_conditional_expression_or_higher().expr;
let value = if self.eat(TokenKind::Equal) {
@@ -5522,7 +5546,7 @@ impl<'src> Parser<'src> {
/// and its name — the parser consumes the modifier keywords but does not
/// record them on the `Parameter` node. A `private` prefix name-mangles the
/// synthesised attribute to `self.__name`
- fn synthesize_let_assignments(&self, params: &ast::Parameters) -> Vec {
+ fn synthesize_let_assignments(&mut self, params: &ast::Parameters) -> Vec {
let mut out = Vec::new();
for p in ¶ms.posonlyargs {
self.maybe_synth_let_assign(&p.parameter, &mut out);
@@ -5542,7 +5566,7 @@ impl<'src> Parser<'src> {
out
}
- fn maybe_synth_let_assign(&self, param: &ast::Parameter, out: &mut Vec) {
+ fn maybe_synth_let_assign(&mut self, param: &ast::Parameter, out: &mut Vec) {
let prefix_start = usize::from(param.range.start());
let prefix_end = usize::from(param.name.range.start());
let prefix = &self.source[prefix_start..prefix_end];
@@ -5557,6 +5581,11 @@ impl<'src> Parser<'src> {
// the visibility is the annotation marker's job, below
let visibility = prefix_visibility(prefix);
let attr_id = name_id.clone();
+ // the parser writes the assignment, but the author wrote the name: both
+ // nodes carry the parameter's own name, so the attribute it declares is
+ // the author's to rename and the read of the parameter is not a use
+ self.record_generated_name(param.range, attr_id.clone(), GeneratedName::AuthorName);
+ self.record_generated_name(name_range, name_id.clone(), GeneratedName::AuthorName);
let self_expr = Expr::Name(ast::ExprName {
id: Name::new_static("self"),
ctx: ExprContext::Load,
@@ -5979,6 +6008,7 @@ impl<'src> Parser<'src> {
let rewriter = FieldRewriter {
backing: backing.clone(),
seen: std::cell::Cell::new(false),
+ rewritten: std::cell::RefCell::new(Vec::new()),
};
{
use ruff_python_ast::visitor::transformer::Transformer;
@@ -5993,6 +6023,9 @@ impl<'src> Parser<'src> {
}
}
}
+ for range in rewriter.rewritten.take() {
+ self.record_generated_name(range, backing.clone(), GeneratedName::ParserName);
+ }
let references_field = rewriter.seen.get();
if is_let && setter.is_some() {
@@ -6168,6 +6201,11 @@ impl<'src> Parser<'src> {
range: TextRange::empty(field_range.start()),
node_index: AtomicNodeIndex::NONE,
});
+ self.record_generated_name(
+ TextRange::empty(field_range.start()),
+ backing.clone(),
+ GeneratedName::ParserName,
+ );
match (backing_type, backing_init) {
(Some(annotation), value) => {
backing_stmt = Some(Stmt::AnnAssign(ast::StmtAnnAssign {
@@ -6243,6 +6281,13 @@ impl<'src> Parser<'src> {
range: construct_range,
node_index: AtomicNodeIndex::NONE,
};
+ if getter.is_none() {
+ self.record_generated_name(
+ TextRange::empty(start),
+ backing.clone(),
+ GeneratedName::ParserName,
+ );
+ }
let (getter_body, getter_range) = match getter {
Some((body, range)) => (body, range),
// `var` with only a setter gets a pass-through getter
@@ -6295,6 +6340,11 @@ impl<'src> Parser<'src> {
None => {
let target =
synth_backing_attr(&backing, ExprContext::Store, construct_range.end());
+ self.record_generated_name(
+ TextRange::empty(construct_range.end()),
+ backing,
+ GeneratedName::ParserName,
+ );
(
vec![Stmt::Assign(ast::StmtAssign {
targets: vec![target],
@@ -7714,7 +7764,9 @@ impl<'src> Parser<'src> {
let mut body = self.parse_body_inner(parent_clause);
let properties = std::mem::replace(&mut self.pending_narrow_props, saved);
if !properties.is_empty() {
- narrow_property_reads(&mut body, &properties);
+ for (range, name) in narrow_property_reads(&mut body, &properties) {
+ self.record_generated_name(range, name, GeneratedName::ParserName);
+ }
}
body
} else {
diff --git a/crates/ruff_server/src/edit.rs b/crates/ruff_server/src/edit.rs
index 619c03af2c..3ae138df2b 100644
--- a/crates/ruff_server/src/edit.rs
+++ b/crates/ruff_server/src/edit.rs
@@ -113,10 +113,13 @@ impl WorkspaceEditTracker {
}
/// Sets a series of [`Fixes`] for a text or notebook document.
+ ///
+ /// `version` is the version the fixes were computed against — see
+ /// [`DocumentQuery::edit_version`](crate::session::DocumentQuery::edit_version).
pub(crate) fn set_fixes_for_document(
&mut self,
fixes: Fixes,
- version: DocumentVersion,
+ version: Option,
) -> crate::Result<()> {
#[expect(
clippy::iter_over_hash_type,
@@ -134,7 +137,7 @@ impl WorkspaceEditTracker {
pub(crate) fn set_edits_for_document(
&mut self,
uri: Uri,
- _version: DocumentVersion,
+ version: Option,
edits: Vec,
) -> crate::Result<()> {
match self {
@@ -150,8 +153,9 @@ impl WorkspaceEditTracker {
document_edits.push(lsp_types::TextDocumentEdit {
text_document: lsp_types::OptionalVersionedTextDocumentIdentifier {
text_document_identifier: TextDocumentIdentifier { uri },
- // TODO(jane): Re-enable versioned edits after investigating whether it could work with notebook cells
- version: None,
+ // a client applying an edit to a document that has moved on since would
+ // land every range in the wrong place; the version is what lets it refuse
+ version,
},
edits: edits.into_iter().map(lsp_types::Edit::TextEdit).collect(),
});
diff --git a/crates/ruff_server/src/server/api/requests/code_action.rs b/crates/ruff_server/src/server/api/requests/code_action.rs
index 8ff0aceab8..09c34eb53a 100644
--- a/crates/ruff_server/src/server/api/requests/code_action.rs
+++ b/crates/ruff_server/src/server/api/requests/code_action.rs
@@ -179,7 +179,7 @@ fn quick_fix(
tracker.set_edits_for_document(
document_uri.clone(),
- document.version(),
+ document.edit_version(),
fix.edits.clone(),
)?;
@@ -209,7 +209,7 @@ fn noqa_comments(snapshot: &DocumentSnapshot, fixes: &[DiagnosticFix]) -> Vec crate::Result {
let mut tracker = WorkspaceEditTracker::new(client_capabilities);
- tracker.set_fixes_for_document(fix_all_edit(query, encoding)?, query.version())?;
+ tracker.set_fixes_for_document(fix_all_edit(query, encoding)?, query.edit_version())?;
Ok(tracker.into_workspace_edit())
}
@@ -153,7 +153,10 @@ pub(super) fn resolve_edit_for_organize_imports(
encoding: PositionEncoding,
) -> crate::Result {
let mut tracker = WorkspaceEditTracker::new(client_capabilities);
- tracker.set_fixes_for_document(organize_imports_edit(query, encoding)?, query.version())?;
+ tracker.set_fixes_for_document(
+ organize_imports_edit(query, encoding)?,
+ query.edit_version(),
+ )?;
Ok(tracker.into_workspace_edit())
}
@@ -214,7 +217,10 @@ pub(super) fn resolve_edit_for_optimize_imports(
encoding: PositionEncoding,
) -> crate::Result {
let mut tracker = WorkspaceEditTracker::new(client_capabilities);
- tracker.set_fixes_for_document(optimize_imports_edit(query, encoding)?, query.version())?;
+ tracker.set_fixes_for_document(
+ optimize_imports_edit(query, encoding)?,
+ query.edit_version(),
+ )?;
Ok(tracker.into_workspace_edit())
}
@@ -223,7 +229,10 @@ pub(super) fn resolve_edit_for_format_and_optimize_imports(
) -> crate::Result {
let query = snapshot.query();
let mut tracker = WorkspaceEditTracker::new(snapshot.resolved_client_capabilities());
- tracker.set_fixes_for_document(format_and_optimize_imports_edit(snapshot)?, query.version())?;
+ tracker.set_fixes_for_document(
+ format_and_optimize_imports_edit(snapshot)?,
+ query.edit_version(),
+ )?;
Ok(tracker.into_workspace_edit())
}
@@ -232,7 +241,10 @@ pub(super) fn resolve_edit_for_format_and_organize_imports(
) -> crate::Result {
let query = snapshot.query();
let mut tracker = WorkspaceEditTracker::new(snapshot.resolved_client_capabilities());
- tracker.set_fixes_for_document(format_and_organize_imports_edit(snapshot)?, query.version())?;
+ tracker.set_fixes_for_document(
+ format_and_organize_imports_edit(snapshot)?,
+ query.edit_version(),
+ )?;
Ok(tracker.into_workspace_edit())
}
diff --git a/crates/ruff_server/src/server/api/requests/execute_command.rs b/crates/ruff_server/src/server/api/requests/execute_command.rs
index 360671d8d1..ab59b70d99 100644
--- a/crates/ruff_server/src/server/api/requests/execute_command.rs
+++ b/crates/ruff_server/src/server/api/requests/execute_command.rs
@@ -100,13 +100,16 @@ impl super::SyncRequestHandler for ExecuteCommand {
)
.with_failure_code(ErrorCode::InternalError)?;
edit_tracker
- .set_fixes_for_document(fixes, snapshot.query().version())
+ .set_fixes_for_document(fixes, snapshot.query().edit_version())
.with_failure_code(ErrorCode::InternalError)?;
}
SupportedCommand::Format => {
let fixes = super::format::format_full_document(&snapshot)?;
edit_tracker
- .set_fixes_for_document(fixes, version)
+ .set_fixes_for_document(
+ fixes,
+ snapshot.query().edit_version().map(|_| version),
+ )
.with_failure_code(ErrorCode::InternalError)?;
}
SupportedCommand::OrganizeImports => {
@@ -116,7 +119,7 @@ impl super::SyncRequestHandler for ExecuteCommand {
)
.with_failure_code(ErrorCode::InternalError)?;
edit_tracker
- .set_fixes_for_document(fixes, snapshot.query().version())
+ .set_fixes_for_document(fixes, snapshot.query().edit_version())
.with_failure_code(ErrorCode::InternalError)?;
}
SupportedCommand::OptimizeImports => {
@@ -126,7 +129,7 @@ impl super::SyncRequestHandler for ExecuteCommand {
)
.with_failure_code(ErrorCode::InternalError)?;
edit_tracker
- .set_fixes_for_document(fixes, snapshot.query().version())
+ .set_fixes_for_document(fixes, snapshot.query().edit_version())
.with_failure_code(ErrorCode::InternalError)?;
}
SupportedCommand::FormatAndOptimizeImports => {
@@ -134,7 +137,7 @@ impl super::SyncRequestHandler for ExecuteCommand {
super::code_action_resolve::format_and_optimize_imports_edit(&snapshot)
.with_failure_code(ErrorCode::InternalError)?;
edit_tracker
- .set_fixes_for_document(fixes, snapshot.query().version())
+ .set_fixes_for_document(fixes, snapshot.query().edit_version())
.with_failure_code(ErrorCode::InternalError)?;
}
SupportedCommand::Debug => {
diff --git a/crates/ruff_server/src/session/index.rs b/crates/ruff_server/src/session/index.rs
index a5055ecb07..846770e869 100644
--- a/crates/ruff_server/src/session/index.rs
+++ b/crates/ruff_server/src/session/index.rs
@@ -620,6 +620,18 @@ impl DocumentQuery {
}
}
+ /// The version to stamp on an edit computed from this query, so a client can refuse to apply
+ /// it to a document that has changed since.
+ ///
+ /// Only a text document has one. The edits for a notebook are addressed to its cells, and a
+ /// cell has no version of its own to compare against — the notebook's version is not it.
+ pub(crate) fn edit_version(&self) -> Option {
+ match self {
+ Self::Text { document, .. } => Some(document.version()),
+ Self::Notebook { .. } => None,
+ }
+ }
+
/// Get the URI for the document selected by this query.
fn file_uri(&self) -> &Uri {
match self {
diff --git a/crates/ruff_server/tests/e2e/code_action.rs b/crates/ruff_server/tests/e2e/code_action.rs
index 7a0d4333f6..1e0c0fc52c 100644
--- a/crates/ruff_server/tests/e2e/code_action.rs
+++ b/crates/ruff_server/tests/e2e/code_action.rs
@@ -428,3 +428,55 @@ fn format_and_optimize_imports_resolves_deferred() -> Result<()> {
Ok(())
}
+
+/// A source action's edit names the version of the document it was computed against, so a client
+/// that has moved on since — the user typed while the fix was being worked out — can refuse to
+/// apply ranges that no longer point where they did.
+#[test]
+fn source_action_edit_carries_the_document_version() -> Result<()> {
+ let mut server = TestServerBuilder::new()?
+ .with_workspace(".")?
+ .enable_document_changes(true)
+ .build();
+
+ server.open_text_document("test.py", "x = 1\n", 1);
+ server.change_text_document(
+ "test.py",
+ vec![
+ lsp_types::TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
+ lsp_types::TextDocumentContentChangeWholeDocument {
+ text: NEEDS_EVERYTHING.to_string(),
+ },
+ ),
+ ],
+ 7,
+ );
+
+ let actions = server
+ .code_action_request_only(
+ "test.py",
+ vec![CodeActionKind::new("source.optimizeImports.ruff")],
+ )
+ .expect("Expected Some response");
+
+ let [CodeActionResponse::CodeAction(action)] = actions.as_slice() else {
+ panic!("Expected exactly one code action, got {actions:?}");
+ };
+ let Some(changes) = action
+ .edit
+ .as_ref()
+ .and_then(|edit| edit.document_changes.as_ref())
+ else {
+ panic!("Expected document changes, got {:?}", action.edit);
+ };
+ let versions: Vec<_> = changes
+ .iter()
+ .map(|change| match change {
+ lsp_types::DocumentChange::TextDocumentEdit(edit) => edit.text_document.version,
+ other => panic!("Expected a text edit, got {other:?}"),
+ })
+ .collect();
+ assert_eq!(versions, vec![Some(7)]);
+
+ Ok(())
+}
diff --git a/crates/ruff_server/tests/e2e/main.rs b/crates/ruff_server/tests/e2e/main.rs
index 5b4121d1a5..0ae2887ef9 100644
--- a/crates/ruff_server/tests/e2e/main.rs
+++ b/crates/ruff_server/tests/e2e/main.rs
@@ -711,7 +711,6 @@ impl TestServer {
}
/// Send a `textDocument/didChange` notification with the given content changes
- #[expect(dead_code)]
pub(crate) fn change_text_document(
&mut self,
path: impl AsRef,
@@ -1131,6 +1130,18 @@ impl TestServerBuilder {
self
}
+ /// Claims the client reads a workspace edit's `documentChanges`, the shape that can say which
+ /// version of a document its edits were computed against.
+ pub(crate) fn enable_document_changes(mut self, enabled: bool) -> Self {
+ self.client_capabilities
+ .workspace
+ .get_or_insert_default()
+ .workspace_edit
+ .get_or_insert_default()
+ .document_changes = Some(enabled);
+ self
+ }
+
/// Lets the server ask the client to apply a workspace edit, which is how the
/// `workspace/executeCommand` handlers deliver their result.
pub(crate) fn enable_apply_edit(mut self, enabled: bool) -> Self {
diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md
index 5c8dc6475b..4d1ca9f79d 100644
--- a/crates/ty/docs/cli.md
+++ b/crates/ty/docs/cli.md
@@ -21,7 +21,7 @@ by
by runTranspile and run a module with python -m <module>
by initStart a new project
by buildBuild the project as python
-by restageRecompute one file's slot in a build tree that already exists
+by restageRecompute the slots of a set of edited files in a build tree that already exists
by compileCompile .by and .py files to native CPython extension modules
by generate-api-fileGenerate an api lockfile (api.lock) summarising the public type-level surface of the project
by transpileTranspile a file to stdout, or a whole directory in place (reads stdin if no path given)
@@ -256,6 +256,10 @@ by run [OPTIONS] [MODULE] [ARGS]...
A function the compiler declines still runs — from the interpreted source embedded in the extension — so this changes speed, not behaviour. Needs a C toolchain and python development headers.
The entry module itself stays interpreted: running something as __main__ needs a code object, and an extension module has none.
--help, -hPrint help (see a summary with '-h')
+--in-buildRun in the tree this staged instead of in the current directory.
+For a program whose arguments are the project's own files — a test runner, a coverage run, a type checker over the output. Those files are python only in the staged tree, so a path naming one resolves only from inside it. Everything else wants the default: a program that reads or writes beside the project has to see the directory it was started in.
+--launcher pathA program to start the interpreter through, such as a debugger that has to be the process the program runs in.
+The interpreter is chosen exactly as it is without this, and the program is started as LAUNCHER <interpreter> <runner> <module> <args>: the launcher is handed the interpreter by run chose rather than having to name one, and is run once, for the program — never for the version probe, which is made against the interpreter itself.
--min-version versionminimum Python version the output must run on [default: the version of the interpreter that will run it]
--no-unique-loop-bindingsleave a closure made inside a loop sharing the loop's one binding, as python does, instead of binding the values of the iteration it was made in
--python, --venv pathThe interpreter to run on, or the environment holding it.
@@ -308,7 +312,7 @@ by build [OPTIONS]
--help, -hPrint help (see a summary with '-h')
--min-version versionminimum Python version the output must run on [default: the project's configured python version]
--no-unique-loop-bindingsleave a closure made inside a loop sharing the loop's one binding, as python does, instead of binding the values of the iteration it was made in
---out, -o dirWhere to write the output [default: build, or dist with --wheels]
+--out, -o dirWhere to write the output [default: build in the project root, or dist with --wheels]
--print-manifestReport what the build read and produced, as <kind> <value> lines.
input <path> for every file the project is made of — what a source distribution has to carry to rebuild into the same thing — and package <name> for every top-level package that came out.
--runtime-raises-checkswrap every function with a raises clause in a runtime guard that fails when it raises something the clause does not include
@@ -320,9 +324,9 @@ by build [OPTIONS]
## by restage
-Recompute one file's slot in a build tree that already exists.
+Recompute the slots of a set of edited files in a build tree that already exists.
-What a debugger needs to give a running program an edit. `by run` transpiles the project into a directory and runs the program out of there, so nothing the user edits is the file the interpreter compiled: a `.by` because it was transpiled, a hand-written `.py` because it was copied. This produces what that file's slot in the tree should now hold.
+What a debugger needs to give a running program an edit. `by run` transpiles the project into a directory and runs the program out of there, so nothing the user edits is the file the interpreter compiled: a `.by` because it was transpiled, a hand-written `.py` because it was copied. This produces what each file's slot in the tree should now hold, and the one `_by_sourcemap.py` that describes all of them together.
It **writes nothing** and prints JSON. The caller writes the bytes, because the caller is the only one that can take the write back when the replacement it was for is refused.
@@ -331,13 +335,13 @@ Editors should send `by/transpileForBuild` to the language server instead, which
Usage
```
-by restage
+by restage ...
```
Arguments
BUILD_DIRECTORYThe build tree the program is running out of
-FILEThe file in the project that was edited
+FILESThe files in the project that were edited. Name every file of one edit together: they share a sourcemap, and re-staging them one at a time answers with a map per file that each drop the others' entries
Options
@@ -375,9 +379,9 @@ by compile [OPTIONS] [FILE]...
--no-unique-loop-bindingsleave a closure made inside a loop sharing the loop's one binding, as python does, instead of binding the values of the iteration it was made in
--no-verify-installLeave out the import-time check that every class reported as compiled is the class standing under its own name.
The check runs once per module at import and raises ImportError naming the class when what was installed is not what was emitted for it. It is on by default: a class that quietly leaves its interpreted definition standing answers exactly as that definition does, so nothing else detects one.
---out, --output, -o dirWhere to write the generated C and the extension modules.
+--out, --output, -o dirWhere to write the generated C and the extension modules [default: build in the project root].
The same directory by build writes, and the same flag names it — --output is kept because it was the only spelling this command took.
-[default: build]
--require-nativeFail the build if any function is left to its interpreted definition, whatever the reason.
+--require-nativeFail the build if any function is left to its interpreted definition, whatever the reason.
Stricter than --no-any, and a different question: --no-any asks whether the module is fully typed, this asks whether it compiles entirely.
--runtime-raises-checkswrap every function with a raises clause in a runtime guard that fails when it raises something the clause does not include
--soundness specwhich runtime type-soundness checks to insert: default, all (adds the opt-in parameters entry checks), none, or a comma-separated subset of generic-calls, projections, iterations, assignments, returns, arguments, parameters
diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md
index f51de6864e..84eb6ec21e 100644
--- a/crates/ty/docs/rules.md
+++ b/crates/ty/docs/rules.md
@@ -8,7 +8,7 @@
Default level: error ·
Added in 0.0.64 ·
Related issues ·
-View source
+View source
@@ -44,7 +44,7 @@ class Base(ABC):
Default level: error ·
Added in 0.0.13 ·
Related issues ·
-View source
+View source
@@ -90,7 +90,7 @@ class Derived(Base): # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.61 ·
Related issues ·
-View source
+View source
@@ -123,7 +123,7 @@ f(1, b=s1) # ok — explicit
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.39 ·
Related issues ·
-View source
+View source
@@ -159,7 +159,7 @@ report(Celsius()) # error: two conversions apply
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.3 ·
Related issues ·
-View source
+View source
@@ -194,7 +194,7 @@ extension list:
Default level: warn ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -258,7 +258,7 @@ class SubProto(BaseProto, Protocol):
Default level: error ·
Added in 0.0.14 ·
Related issues ·
-View source
+View source
@@ -340,7 +340,7 @@ value = unknown # ty: ignore[unresolved-reference]
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.61 ·
Related issues ·
-View source
+View source
@@ -383,7 +383,7 @@ a4 = True + 1 # ok — a boolean used as a boolean
Default level: error ·
Added in 0.0.16 ·
Related issues ·
-View source
+View source
@@ -435,7 +435,7 @@ Foo.method() # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -463,7 +463,7 @@ Calling a non-callable object will raise a `TypeError` at runtime.
Default level: error ·
Added in 0.0.7 ·
Related issues ·
-View source
+View source
@@ -498,7 +498,7 @@ def f(x: object):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -546,7 +546,7 @@ def main():
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -593,7 +593,7 @@ def Fixed(show: bool):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -626,7 +626,7 @@ a = 1 # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -660,7 +660,7 @@ class C(A, B): ... # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -706,7 +706,7 @@ def App(done: bool) -> int:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -740,7 +740,7 @@ class B(A): ... # error
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -785,7 +785,7 @@ type Tree = int | list[Tree] # valid recursive alias
Default level: error ·
Added in 0.0.15 ·
Related issues ·
-View source
+View source
@@ -821,7 +821,7 @@ class Example:
Default level: warn ·
Added in 0.0.1-alpha.16 ·
Related issues ·
-View source
+View source
@@ -860,7 +860,7 @@ old_func() # error: [deprecated]
Default level: ignore ·
Added in 0.0.78 ·
Related issues ·
-View source
+View source
@@ -1032,7 +1032,7 @@ soundness checks from their type checker, and it may have false positives in som
Default level: error · Level under ty-compatible: ignore ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1064,7 +1064,7 @@ This rule is currently disabled by default because of the number of false positi
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1095,7 +1095,7 @@ class B(A, A): ... # error
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -1135,7 +1135,7 @@ class A: # error
Default level: ignore ·
Added in 0.0.73 ·
Related issues ·
-View source
+View source
@@ -1246,7 +1246,7 @@ Python code.
Default level: error ·
Added in 0.0.14 ·
Related issues ·
-View source
+View source
@@ -1295,7 +1295,7 @@ def bar() -> str: # error: [empty-body]
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.61 ·
Related issues ·
-View source
+View source
@@ -1356,7 +1356,7 @@ def h(x: object):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.3 ·
Related issues ·
-View source
+View source
@@ -1458,7 +1458,7 @@ def foo() -> "intt\b": ... # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1497,7 +1497,7 @@ def f(local fn: () -> None):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1538,7 +1538,7 @@ for x in [1, 2, 3]:
Default level: warn ·
Added in 0.0.50 ·
Related issues ·
-View source
+View source
@@ -1578,7 +1578,7 @@ def g(value: ~A) -> None: ... # error: [experimental-syntax]
Default level: error ·
Added in 0.0.20 ·
Related issues ·
-View source
+View source
@@ -1612,7 +1612,7 @@ def my_function() -> int:
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.40 ·
Related issues ·
-View source
+View source
@@ -1647,7 +1647,7 @@ let a = 1
Default level: error ·
Added in 0.0.15 ·
Related issues ·
-View source
+View source
@@ -1764,7 +1764,7 @@ def test() -> "Literal[5]":
Default level: ignore · basedpython only, so absent under ty-compatible ·
Added in 0.0.72 ·
Related issues ·
-View source
+View source
@@ -1817,7 +1817,7 @@ unpacking — is not a declaration, and is never reported.
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.68 ·
Related issues ·
-View source
+View source
@@ -1892,7 +1892,7 @@ print(Labelled) # warning: prints ``
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.80 ·
Related issues ·
-View source
+View source
@@ -1931,7 +1931,7 @@ def audit(account: Account) -> int:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1967,7 +1967,7 @@ class C(A, B): ... # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1996,7 +1996,7 @@ t[3] # error
Default level: warn ·
Added in 0.0.1-alpha.33 ·
Related issues ·
-View source
+View source
@@ -2033,7 +2033,7 @@ class MyClass: ...
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.79 ·
Related issues ·
-View source
+View source
@@ -2069,7 +2069,7 @@ class Point:
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -2164,7 +2164,7 @@ will produce instances with an atypical memory layout.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2196,7 +2196,7 @@ func("foo") # error: [invalid-argument-type]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2226,7 +2226,7 @@ a: int = "" # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2307,7 +2307,7 @@ box.value = 1 # okay
Default level: error ·
Added in 0.0.33 ·
Related issues ·
-View source
+View source
@@ -2352,7 +2352,7 @@ class Sub(Base):
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -2394,7 +2394,7 @@ asyncio.run(main())
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2421,7 +2421,7 @@ class A(42): ... # error: [invalid-base]
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.79 ·
Related issues ·
-View source
+View source
@@ -2459,7 +2459,7 @@ build: # error: `build` is an experimental feature, and is off
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.5 ·
Related issues ·
-View source
+View source
@@ -2496,7 +2496,7 @@ extension str(A): # error: `str` does not answer every member of `A`
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2525,7 +2525,7 @@ with 1: # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.39 ·
Related issues ·
-View source
+View source
@@ -2558,7 +2558,7 @@ class Fahrenheit:
Default level: error ·
Added in 0.0.12 ·
Related issues ·
-View source
+View source
@@ -2611,7 +2611,7 @@ See:
Default level: error ·
Added in 0.0.13 ·
Related issues ·
-View source
+View source
@@ -2647,7 +2647,7 @@ class A:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2679,7 +2679,7 @@ a: str # error
Default level: warn ·
Added in 0.0.20 ·
Related issues ·
-View source
+View source
@@ -2735,7 +2735,7 @@ class Pet(Enum):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2800,7 +2800,7 @@ This rule corresponds to Ruff's
Default level: error ·
Added in 0.0.1-alpha.28 ·
Related issues ·
-View source
+View source
@@ -2854,7 +2854,7 @@ class D(A):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.3 ·
Related issues ·
-View source
+View source
@@ -2887,7 +2887,7 @@ extension list[T: int]: # error: `list` declares no type parameter `T`
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.4 ·
Related issues ·
-View source
+View source
@@ -2916,7 +2916,7 @@ Author.objects.filter(name__startswith=1) # error: lookup wants `str`
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.36 ·
Related issues ·
-View source
+View source
@@ -2952,7 +2952,7 @@ def test_user(user: int) -> None: # error: fixture provides `str`
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.68 ·
Related issues ·
-View source
+View source
@@ -3000,7 +3000,7 @@ f"{'name':>10}" # ok
Default level: error ·
Added in 0.0.1-alpha.35 ·
Related issues ·
-View source
+View source
@@ -3050,7 +3050,7 @@ class NonFrozenChild(FrozenBase): # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3105,7 +3105,7 @@ class E(Generic[V]):
Default level: error ·
Added in 0.0.12 ·
Related issues ·
-View source
+View source
@@ -3200,7 +3200,7 @@ a = 20 / 0 # type: ignore
Default level: error ·
Added in 0.0.1-alpha.17 ·
Related issues ·
-View source
+View source
@@ -3247,7 +3247,7 @@ carol = Person(name="Carol", aeg=25) # typo!
Default level: warn ·
Added in 0.0.15 ·
Related issues ·
-View source
+View source
@@ -3308,7 +3308,7 @@ def f(x, y, /): # Python 3.8+ syntax
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3348,7 +3348,7 @@ def f(t: TypeVar("U")): ... # ty: ignore[invalid-type-form]
Default level: error ·
Added in 0.0.18 ·
Related issues ·
-View source
+View source
@@ -3399,7 +3399,7 @@ match object():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3433,7 +3433,7 @@ class B(metaclass=42): ... # error
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -3550,7 +3550,7 @@ Correct use of `@override` is enforced by ty's [`invalid-explicit-override`](#in
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.72 ·
Related issues ·
-View source
+View source
@@ -3593,7 +3593,7 @@ implements Backend # error: `Backend` is not a protocol
Default level: error ·
Added in 0.0.72 ·
Related issues ·
-View source
+View source
@@ -3631,7 +3631,7 @@ from module import missing # error
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -3696,7 +3696,7 @@ TypeError: typing.ClassVar[int] is not valid as type argument
Default level: warn ·
Added in 0.0.31 ·
Related issues ·
-View source
+View source
@@ -3742,7 +3742,7 @@ admin[0] # "Alice"
Default level: error ·
Added in 0.0.1-alpha.27 ·
Related issues ·
-View source
+View source
@@ -3780,7 +3780,7 @@ Baz = NewType("Baz", int | str) # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3837,7 +3837,7 @@ def foo(x: int) -> int: ...
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.80 ·
Related issues ·
-View source
+View source
@@ -3875,7 +3875,7 @@ B().f() # 1, not 2
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3903,7 +3903,7 @@ def f(a: int = ""): ... # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.36 ·
Related issues ·
-View source
+View source
@@ -3936,7 +3936,7 @@ def test_add(a: int, b: int) -> None:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -3972,7 +3972,7 @@ P2 = ParamSpec() # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4024,7 +4024,7 @@ Declare the type variable with `TypeVar("T", covariant=True)` instead.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4095,7 +4095,7 @@ def g():
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.37 ·
Related issues ·
-View source
+View source
@@ -4130,7 +4130,7 @@ def g[T](value: T) raises T: # error: `T@g` is not always an exception
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.36 ·
Related issues ·
-View source
+View source
@@ -4163,7 +4163,7 @@ if m := re.match("(a)(b)", "ab"):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -4195,7 +4195,7 @@ type Alias[reified T] = list[T] # error: an alias's parameters are erased
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4341,7 +4341,7 @@ def detail(request, pk: int): ... # ok
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.79 ·
Related issues ·
-View source
+View source
@@ -4380,7 +4380,7 @@ import "data/missing.json" as missing
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4488,7 +4488,7 @@ class C: ...
Default level: error ·
Added in 0.0.10 ·
Related issues ·
-View source
+View source
@@ -4539,7 +4539,7 @@ class MyClass:
Default level: error ·
Added in 0.0.1-alpha.6 ·
Related issues ·
-View source
+View source
@@ -4585,7 +4585,7 @@ InvalidAlias = TypeAliasType("InvalidAlias", list[T], type_params=(list[T],)) #
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -4651,7 +4651,7 @@ Bar[int] # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4684,7 +4684,7 @@ TYPE_CHECKING = "" # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4719,7 +4719,7 @@ b: Annotated[int] # error
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -4776,7 +4776,7 @@ class C:
Default level: error ·
Added in 0.0.15 ·
Related issues ·
-View source
+View source
@@ -4842,7 +4842,7 @@ class Owner[U]:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -4899,7 +4899,7 @@ V = TypeVar("V", list[int], int) # valid constrained Type
Default level: error ·
Added in 0.0.16 ·
Related issues ·
-View source
+View source
@@ -4941,7 +4941,7 @@ U = TypeVar("U", int, str, default=bytes) # error: [invalid-type-variable-defau
Default level: error ·
Added in 0.0.28 ·
Related issues ·
-View source
+View source
@@ -4977,7 +4977,7 @@ class Child(Base):
Default level: error ·
Added in 0.0.14 ·
Related issues ·
-View source
+View source
@@ -5018,7 +5018,7 @@ def f(options: dict[str, object]):
Default level: error ·
Added in 0.0.9 ·
Related issues ·
-View source
+View source
@@ -5052,7 +5052,7 @@ class Foo(TypedDict):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -5093,7 +5093,7 @@ type Alias[out T] = list[T] # error: `list` is invariant
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.80 ·
Related issues ·
-View source
+View source
@@ -5128,7 +5128,7 @@ class Point:
Default level: error ·
Added in 0.0.25 ·
Related issues ·
-View source
+View source
@@ -5162,7 +5162,7 @@ def gen() -> Iterator[int]:
Default level: error ·
Added in 0.0.14 ·
Related issues ·
-View source
+View source
@@ -5228,7 +5228,7 @@ def h(arg2: type):
Default level: error ·
Added in 0.0.15 ·
Related issues ·
-View source
+View source
@@ -5277,7 +5277,7 @@ def g(arg: object):
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.36 ·
Related issues ·
-View source
+View source
@@ -5308,7 +5308,7 @@ def f(s: str):
Default level: warn ·
Added in 0.0.30 ·
Related issues ·
-View source
+View source
@@ -5350,7 +5350,7 @@ Movie = TypedDict("Film", {"title": str}) # error: [mismatched-type-name]
Default level: warn ·
Added in 0.0.1-alpha.39 ·
Related issues ·
-View source
+View source
@@ -5418,7 +5418,7 @@ and nothing is reported.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -5449,7 +5449,7 @@ func() # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.61 ·
Related issues ·
-View source
+View source
@@ -5480,7 +5480,7 @@ f(1) # ok — `s` is passed implicitly
Default level: ignore ·
Preview (since 0.0.76) ·
Related issues ·
-View source
+View source
@@ -5569,7 +5569,7 @@ Add `urllib3` to `project.dependencies` if your code imports it directly.
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.4 ·
Related issues ·
-View source
+View source
@@ -5597,7 +5597,7 @@ from django.db import models # warning: install `django-stubs` for precise type
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.81 ·
Related issues ·
-View source
+View source
@@ -5649,7 +5649,7 @@ def ignore(event: str): ...
Default level: error · Level under ty-compatible: ignore ·
Added in 0.0.41 ·
Related issues ·
-View source
+View source
@@ -5710,7 +5710,7 @@ class ExplicitChild(Parent):
Default level: error ·
Added in 0.0.75 ·
Related issues ·
-View source
+View source
@@ -5797,7 +5797,7 @@ class Item:
Default level: error · Level under ty-compatible: ignore ·
Added in 0.0.45 ·
Related issues ·
-View source
+View source
@@ -5835,7 +5835,7 @@ def handle(m: re.Match[str]) -> str:
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -5874,7 +5874,7 @@ alice["age"] # KeyError
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -5921,7 +5921,7 @@ def App():
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -5941,8 +5941,7 @@ narrowing, and the test is always false.
```by
def check(x: int | None) -> asserts x:
- if x is None:
- raise ValueError
+ assert x
def f(a: int | None):
if check(a): # error: the guard narrows as a statement, not as a test
@@ -5955,7 +5954,7 @@ def f(a: int | None):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -5993,7 +5992,7 @@ func("string") # error: [no-matching-overload]
Default level: error ·
Added in 0.0.30 ·
Related issues ·
-View source
+View source
@@ -6030,7 +6029,7 @@ class Sub(Super): ... # error: [non-callable-init-subclass]
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.39 ·
Related issues ·
-View source
+View source
@@ -6061,7 +6060,7 @@ def f(x: int | str) -> int:
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.61 ·
Related issues ·
-View source
+View source
@@ -6090,7 +6089,7 @@ def f(a: object):
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -6134,7 +6133,7 @@ def g(o: object, shape: Shape):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6163,7 +6162,7 @@ for i in 34: # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6191,7 +6190,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6220,7 +6219,7 @@ def f(once done: () -> None):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6248,7 +6247,7 @@ def f(once done: () -> None):
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.61 ·
Related issues ·
-View source
+View source
@@ -6286,7 +6285,7 @@ def f(x: int?):
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -6341,7 +6340,7 @@ def g(name: str | None):
Default level: error ·
Added in 0.0.1-alpha.29 ·
Related issues ·
-View source
+View source
@@ -6378,7 +6377,7 @@ class B(A):
Default level: error ·
Added in 0.0.16 ·
Related issues ·
-View source
+View source
@@ -6415,7 +6414,7 @@ class B(A):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.38 ·
Related issues ·
-View source
+View source
@@ -6458,7 +6457,7 @@ def main():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6489,7 +6488,7 @@ f(1, x=2) # error
Default level: error ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -6520,7 +6519,7 @@ f(x=1) # error
Default level: error · Level under ty-compatible: ignore ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -6558,7 +6557,7 @@ A.c # error
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -6596,7 +6595,7 @@ A()[0] # error
Default level: error · Level under ty-compatible: ignore ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -6640,7 +6639,7 @@ from module import a # error
Default level: warn ·
Added in 0.0.23 ·
Related issues ·
-View source
+View source
@@ -6672,7 +6671,7 @@ html.parser # error
Default level: error · Level under ty-compatible: ignore ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6708,7 +6707,7 @@ print(x) # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.79 ·
Related issues ·
-View source
+View source
@@ -6747,7 +6746,7 @@ Id("x") # error: `Id`'s constructor is private
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.80 ·
Related issues ·
-View source
+View source
@@ -6778,7 +6777,7 @@ __all__ = ["helper"] # error: `helper` is private
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6810,7 +6809,7 @@ from helpers import Key # error: `Key` is private to `helpers`
Default level: warn ·
Added in 0.0.60 ·
Related issues ·
-View source
+View source
@@ -6885,7 +6884,7 @@ def test() -> "int":
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -6927,7 +6926,7 @@ def g(a: bool | None):
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -6962,7 +6961,7 @@ cast(int, f()) # error
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -7016,7 +7015,7 @@ if sys.version_info >= (3, 12): # ok — artificially constant
Default level: warn ·
Added in 0.0.18 ·
Related issues ·
-View source
+View source
@@ -7054,7 +7053,7 @@ class C:
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -7109,7 +7108,7 @@ class Sub(Base):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.62 ·
Related issues ·
-View source
+View source
@@ -7158,7 +7157,7 @@ def f(value: int | str) -> int:
Default level: error ·
Added in 0.0.71 ·
Related issues ·
-View source
+View source
@@ -7222,7 +7221,7 @@ def g(values: tuple[int, ...]) -> None:
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.3 ·
Related issues ·
-View source
+View source
@@ -7255,7 +7254,7 @@ class C:
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.72 ·
Related issues ·
-View source
+View source
@@ -7291,7 +7290,7 @@ class C[T]:
Default level: warn ·
Added in 0.0.71 ·
Related issues ·
-View source
+View source
@@ -7334,7 +7333,7 @@ def build(t: Tag) -> None:
Default level: error ·
Added in 0.0.20 ·
Related issues ·
-View source
+View source
@@ -7378,7 +7377,7 @@ class Outer[T]:
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -7427,7 +7426,7 @@ def Observed():
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -7471,7 +7470,7 @@ def Counter():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7505,7 +7504,7 @@ static_assert(int(2.0 * 3.0) == 6) # error
Default level: warn ·
Added in 0.0.39 ·
Related issues ·
-View source
+View source
@@ -7557,7 +7556,7 @@ limitation.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7591,7 +7590,7 @@ class B(A): ... # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7623,7 +7622,7 @@ class Circle(Shape): ... # error: `Shape` is sealed in another workspace
Default level: error ·
Added in 0.0.1-alpha.30 ·
Related issues ·
-View source
+View source
@@ -7732,7 +7731,7 @@ class Book:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7762,7 +7761,7 @@ f("foo") # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7801,7 +7800,7 @@ def find(items: list[int]) -> int:
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7848,7 +7847,7 @@ g:
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7883,7 +7882,7 @@ f: # error: the block returns `None`, not `str`
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -7922,7 +7921,7 @@ def _(x: int):
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.4 ·
Related issues ·
-View source
+View source
@@ -7953,7 +7952,7 @@ class User(BaseModel):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8012,7 +8011,7 @@ class A:
Default level: error ·
Added in 0.0.20 ·
Related issues ·
-View source
+View source
@@ -8085,7 +8084,7 @@ the project registers with `@register.simple_block_tag`.
Default level: warn ·
Added in 0.0.1-alpha.39 ·
Related issues ·
-View source
+View source
@@ -8151,7 +8150,7 @@ what the projects depending on it read.
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.37 ·
Related issues ·
-View source
+View source
@@ -8180,7 +8179,7 @@ def f() raises TypeError:
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8203,13 +8202,51 @@ Using `reveal_type` without importing it will raise a `NameError` at runtime.
reveal_type(1) # revealed: Literal[1]
```
+## `unestablished-assertion-guard`
+
+
+Default level: error · basedpython only, so absent under ty-compatible ·
+Added in 0.0.1-alpha.40 ·
+Related issues ·
+View source
+
+
+
+**What it does**
+
+Checks that a function with a basedpython assertion guard establishes what it asserts
+wherever it returns.
+
+**Why is this bad?**
+
+`-> asserts x` tells every caller that `x` is truthy once the call has returned, and each
+call narrows its argument on the strength of that. A body that can return without having
+established it — by falling off its end, or through a `return` — narrows the caller's
+argument to something it may not be.
+
+**Example**
+
+
+```by
+def check(x: int | None) -> asserts x: # error: `x` can still be `0` here
+ if x is None:
+ raise ValueError
+```
+
+Use instead:
+
+```by
+def check(x: int | None) -> asserts x:
+ assert x
+```
+
## `unhandled-exception`
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.37 ·
Related issues ·
-View source
+View source
@@ -8239,7 +8276,7 @@ def main():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8270,7 +8307,7 @@ f(x=1, y=2) # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.36 ·
Related issues ·
-View source
+View source
@@ -8481,7 +8518,7 @@ page does not render at all.
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.72 ·
Related issues ·
-View source
+View source
@@ -8515,7 +8552,7 @@ implements Backend # error: this module does not answer `Backend`
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -8574,7 +8611,7 @@ def Held():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8607,7 +8644,7 @@ A().foo # error
Default level: warn ·
Added in 0.0.1-alpha.15 ·
Related issues ·
-View source
+View source
@@ -8682,7 +8719,7 @@ def g():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8710,7 +8747,7 @@ import foo # error
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8730,8 +8767,7 @@ parameter that was later renamed — silently narrows nothing at every call site
```by
def check(value: int | None) -> asserts values: # error: `values` is nothing
- if value is None:
- raise ValueError
+ assert value
```
## `unresolved-reference`
@@ -8740,7 +8776,7 @@ def check(value: int | None) -> asserts values: # error: `values` is nothing
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -8858,7 +8894,7 @@ is one whose template set cannot be established.
Default level: ignore ·
Added in 0.0.73 ·
Related issues ·
-View source
+View source
@@ -8986,7 +9022,7 @@ Python code.
Default level: error ·
Added in 0.0.71 ·
Related issues ·
-View source
+View source
@@ -9027,7 +9063,7 @@ def f(a: object, b: int, c: Any):
Default level: ignore ·
Added in 0.0.70 ·
Related issues ·
-View source
+View source
@@ -9172,7 +9208,7 @@ Python code.
Default level: ignore ·
Added in 0.0.70 ·
Related issues ·
-View source
+View source
@@ -9319,7 +9355,7 @@ generator boundaries.
Default level: error · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.3 ·
Related issues ·
-View source
+View source
@@ -9369,7 +9405,7 @@ A() # error: nothing says which specialization this is
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.1-alpha.40 ·
Related issues ·
-View source
+View source
@@ -9416,7 +9452,7 @@ def Observed(items: StateList[int]): ... # ok: an observable handle
Default level: warn ·
Added in 0.0.1-alpha.7 ·
Related issues ·
-View source
+View source
@@ -9462,7 +9498,7 @@ class D(C): ... # error: [unsupported-base]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -9511,7 +9547,7 @@ b1 < b2 < b1 # error
Default level: warn · Level under ty-compatible: ignore ·
Added in 0.0.12 ·
Related issues ·
-View source
+View source
@@ -9556,7 +9592,7 @@ def factory(base: type[Base]) -> type:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -9588,7 +9624,7 @@ A() + A() # error
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.79 ·
Related issues ·
-View source
+View source
@@ -9631,7 +9667,7 @@ reveal_type(project.root) # revealed: "."
Default level: warn ·
Added in 0.0.21 ·
Related issues ·
-View source
+View source
@@ -9710,7 +9746,7 @@ to `false` to prevent this rule from reporting unused `type: ignore` comments.
Default level: warn · basedpython only, so absent under ty-compatible ·
Added in 0.0.71 ·
Related issues ·
-View source
+View source
@@ -9790,13 +9826,58 @@ This rule is skipped if
[`analysis.respect-type-ignore-comments`](https://docs.astral.sh/ty/reference/configuration/#respect-type-ignore-comments)
to `false`.
+## `used-underscore-name`
+
+
+Default level: warn · basedpython only, so absent under ty-compatible ·
+Added in 0.0.81 ·
+Related issues ·
+View source
+
+
+
+**What it does**
+
+Checks for a use of a name that a `.by` file spelled with a leading
+underscore: a variable, a parameter, a function, a class, an import, or a
+member of a class declared in a `.by` file.
+
+**Why is this bad?**
+
+basedpython spells privacy with `private` and `protected`, so a leading
+underscore says one thing only: the name is unused. A use contradicts it,
+and a reader who trusts the underscore skips over code that matters.
+
+A name spelled by python or a stub is not reported — a library's
+`_internal`, `_asdict`, an override of a python base class's `_hook` — and
+neither is `_` or a dunder, which python gives meanings of their own.
+
+**Example**
+
+
+```by
+def _parse(text: str) -> int:
+ return int(text)
+
+_parse("1") # warning: `_parse` is used
+```
+
+Drop the underscore, or declare the name `private`:
+
+```by
+private def parse(text: str) -> int:
+ return int(text)
+
+parse("1")
+```
+
## `useless-overload-body`
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -9875,7 +9956,7 @@ def foo(x: int | str) -> int | str:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs
index 4e8e9042ca..606ec53251 100644
--- a/crates/ty/src/args.rs
+++ b/crates/ty/src/args.rs
@@ -87,6 +87,16 @@ pub(crate) enum Command {
/// resolves imports against — then `$PYTHON`, then `python3` on `PATH`.
#[arg(long, value_name = "PATH", alias = "venv")]
python: Option,
+ /// A program to start the interpreter through, such as a debugger that
+ /// has to be the process the program runs in.
+ ///
+ /// The interpreter is chosen exactly as it is without this, and the
+ /// program is started as `LAUNCHER `:
+ /// the launcher is handed the interpreter `by run` chose rather than
+ /// having to name one, and is run once, for the program — never for the
+ /// version probe, which is made against the interpreter itself.
+ #[arg(long, value_name = "PATH")]
+ launcher: Option,
#[command(flatten)]
lowering: LoweringArgs,
/// Compile every imported module to a native extension first.
@@ -99,6 +109,16 @@ pub(crate) enum Command {
/// `__main__` needs a code object, and an extension module has none.
#[arg(long)]
compiled: bool,
+ /// Run in the tree this staged instead of in the current directory.
+ ///
+ /// For a program whose arguments are the project's own files — a test
+ /// runner, a coverage run, a type checker over the output. Those files
+ /// are python only in the staged tree, so a path naming one resolves
+ /// only from inside it. Everything else wants the default: a program
+ /// that reads or writes beside the project has to see the directory it
+ /// was started in.
+ #[arg(long)]
+ in_build: bool,
},
/// Start a new project.
@@ -146,7 +166,7 @@ pub(crate) enum Command {
/// `uv`, which does the packaging.
#[arg(long, conflicts_with_all = ["min_version", "print_manifest"])]
wheels: bool,
- /// Where to write the output [default: `build`, or `dist` with `--wheels`]
+ /// Where to write the output [default: `build` in the project root, or `dist` with `--wheels`]
#[arg(short = 'o', long, value_name = "DIR")]
out: Option,
/// Report what the build read and produced, as ` ` lines.
@@ -160,13 +180,15 @@ pub(crate) enum Command {
lowering: LoweringArgs,
},
- /// Recompute one file's slot in a build tree that already exists.
+ /// Recompute the slots of a set of edited files in a build tree that
+ /// already exists.
///
/// What a debugger needs to give a running program an edit. `by run`
/// transpiles the project into a directory and runs the program out of
/// there, so nothing the user edits is the file the interpreter compiled: a
/// `.by` because it was transpiled, a hand-written `.py` because it was
- /// copied. This produces what that file's slot in the tree should now hold.
+ /// copied. This produces what each file's slot in the tree should now hold,
+ /// and the one `_by_sourcemap.py` that describes all of them together.
///
/// It **writes nothing** and prints JSON. The caller writes the bytes,
/// because the caller is the only one that can take the write back when the
@@ -180,9 +202,11 @@ pub(crate) enum Command {
/// The build tree the program is running out of.
#[arg(value_name = "BUILD_DIR")]
build_directory: PathBuf,
- /// The file in the project that was edited.
- #[arg(value_name = "FILE")]
- file: PathBuf,
+ /// The files in the project that were edited. Name every file of one
+ /// edit together: they share a sourcemap, and re-staging them one at a
+ /// time answers with a map per file that each drop the others' entries.
+ #[arg(value_name = "FILE", required = true)]
+ files: Vec,
},
/// Compile .by and .py files to native CPython extension modules.
@@ -195,7 +219,8 @@ pub(crate) enum Command {
/// project root.
#[arg(value_name = "FILE")]
files: Vec,
- /// Where to write the generated C and the extension modules.
+ /// Where to write the generated C and the extension modules
+ /// [default: `build` in the project root].
///
/// The same directory `by build` writes, and the same flag names it —
/// `--output` is kept because it was the only spelling this command took.
@@ -203,10 +228,9 @@ pub(crate) enum Command {
short = 'o',
long = "out",
visible_alias = "output",
- value_name = "DIR",
- default_value = "build"
+ value_name = "DIR"
)]
- output: PathBuf,
+ output: Option,
/// Report every function that was not lowered natively, with the reason.
#[arg(long)]
verbose: bool,
diff --git a/crates/ty/src/by_commands.rs b/crates/ty/src/by_commands.rs
index 8d9eb0aa55..10677d83e0 100644
--- a/crates/ty/src/by_commands.rs
+++ b/crates/ty/src/by_commands.rs
@@ -123,15 +123,38 @@ impl LoweringArgs {
// ── run ──────────────────────────────────────────────────────────────────────
+/// How `by run` was invoked, beyond what it is running.
+///
+/// The four choices a caller makes about the *process*: which interpreter, what
+/// stands in front of it, whether its modules are compiled first, and which
+/// directory it runs in. [`CompileFlags`] groups `by compile`'s for the same
+/// reason.
+#[derive(Debug, Clone, Copy, Default)]
+pub(crate) struct RunFlags<'a> {
+ /// Compile every imported module to a native extension first.
+ pub(crate) compiled: bool,
+ /// Run the program in the tree the run staged rather than where it was typed.
+ pub(crate) in_build: bool,
+ /// `--python`: the interpreter, or the environment holding it.
+ pub(crate) python: Option<&'a Path>,
+ /// `--launcher`: a program to start the interpreter through.
+ pub(crate) launcher: Option<&'a Path>,
+}
+
#[allow(clippy::exit, clippy::print_stderr)]
pub(crate) fn cmd_run(
module: Option<&str>,
args: &[String],
min_version: Option<&str>,
lowering: &LoweringArgs,
- compiled: bool,
- python_flag: Option<&Path>,
+ flags: RunFlags<'_>,
) -> anyhow::Result {
+ let RunFlags {
+ compiled,
+ in_build,
+ python: python_flag,
+ launcher,
+ } = flags;
let cwd = std::env::current_dir().context("failed to get current directory")?;
// one resolution of the project, for the environment and the target version
// both: they are two readings of the same configuration and must not be able
@@ -199,7 +222,7 @@ pub(crate) fn cmd_run(
// project with neither fails immediately rather than after transpiling
let module = match module {
Some(module) => module.to_owned(),
- None => match configured_main(&db) {
+ None => match by_stage::run_module::configured_main(&db) {
Some(main) => main,
None => anyhow::bail!(
"no module given and no entry point configured — \
@@ -318,18 +341,43 @@ pub(crate) fn cmd_run(
// relative path on its command line, and anything it reads or writes beside
// the project, resolve against the directory they were written for. python
// puts the runner's own directory — the generated tree — at the head of
- // `sys.path`, so the module is still found there
- let status = Command::new(&python)
+ // `sys.path`, so the module is still found there.
+ //
+ // a launcher goes in front of the interpreter rather than in place of it. a
+ // debugger that has to *be* the process the program runs in used to get
+ // there as `--python`, which switched this discovery off and left it to name
+ // an interpreter it could only guess at — and to tell the version probe apart
+ // from the program, since both were sent to it
+ let mut command = match launcher {
+ Some(launcher) => {
+ let mut command = Command::new(launcher);
+ command.arg(&python);
+ command
+ }
+ None => Command::new(&python),
+ };
+ // `--in-build` runs it in that tree instead, for the programs whose
+ // arguments are the project's own files: the project is python only there,
+ // so `pytest tests/test_x.py` finds a file at all only from inside it — and
+ // the whole project is inside it, configuration and data included, so what
+ // the program makes of the tree is what it would have made of the project
+ let workdir = if in_build { tmp.path() } else { cwd.as_path() };
+ let status = command
.arg(tmp.path().join(BY_RUNNER_FILENAME))
.arg(&module)
.args(args)
- .current_dir(&cwd)
+ .current_dir(workdir)
.status()
- .with_context(|| {
- format!(
+ .with_context(|| match launcher {
+ Some(launcher) => format!(
+ "could not run the launcher `{}` for `{python}`, the interpreter from {}",
+ launcher.display(),
+ interpreter.origin
+ ),
+ None => format!(
"could not run `{python}`, the interpreter from {}",
interpreter.origin
- )
+ ),
})?;
let code = status.code().unwrap_or(1);
@@ -544,13 +592,6 @@ fn compiled_module_name(
})
}
-/// The project's `run.main` entry point, if one is configured.
-fn configured_main(db: &ProjectDatabase) -> Option {
- let options = db.project().metadata(db).options();
- let main = options.run.as_ref()?.main.as_ref()?;
- Some((**main).clone())
-}
-
/// The python version a brand new project should target.
///
/// The environment it will be developed in is the right answer when there is
@@ -822,7 +863,8 @@ fn detect_python_version(python: &str) -> Option {
// ── build ────────────────────────────────────────────────────────────────────
#[allow(clippy::print_stderr)]
-/// Recompute one file's slot in a build tree that already exists, and print it.
+/// Recompute the slots of a set of edited files in a build tree that already
+/// exists, and print them.
///
/// The command-line half of `by/transpileForBuild`, over the same implementation:
/// the language server answers this out of a database that is already warm,
@@ -835,16 +877,21 @@ fn detect_python_version(python: &str) -> Option {
/// non-zero: a caller in a script should be able to read `$?` rather than parse
/// to find out whether it got bytes.
#[allow(clippy::print_stdout)]
-pub(crate) fn cmd_restage(build_directory: &Path, file: &Path) -> anyhow::Result {
+pub(crate) fn cmd_restage(build_directory: &Path, files: &[PathBuf]) -> anyhow::Result {
let cwd = std::env::current_dir().context("failed to get current directory")?;
- let file = if file.is_absolute() {
- file.to_path_buf()
- } else {
- cwd.join(file)
- };
+ let files: Vec = files
+ .iter()
+ .map(|file| {
+ if file.is_absolute() {
+ file.clone()
+ } else {
+ cwd.join(file)
+ }
+ })
+ .collect();
let (db, _, _, _) = build_project_db(&cwd, BY_SOURCES, Some(build_directory))?;
- let restaged = by_stage::restage::restage_one(&db, build_directory, &file)?;
+ let restaged = by_stage::restage::restage(&db, build_directory, &files)?;
println!(
"{}",
serde_json::to_string_pretty(&restaged)
@@ -860,7 +907,7 @@ pub(crate) fn cmd_restage(build_directory: &Path, file: &Path) -> anyhow::Result
pub(crate) fn cmd_build(
min_version: Option<&str>,
lowering: &LoweringArgs,
- out: &Path,
+ out: Option<&Path>,
print_manifest: bool,
) -> anyhow::Result {
let cwd = std::env::current_dir().context("failed to get current directory")?;
@@ -869,7 +916,7 @@ pub(crate) fn cmd_build(
let target = config.min_version.to_string();
crate::by_stamps::fill_discovered(&mut config.stamps, &cwd, Some(&target));
- let out = settled_output_dir(&cwd, out);
+ let out = output_dir(&cwd, out)?;
let (db, handles, rebuilder, root) = build_project_db(&cwd, BY_SOURCES, Some(&out))?;
if handles.is_empty() {
@@ -969,6 +1016,28 @@ fn print_build_manifest(
Ok(())
}
+/// Where a command that writes the build tree writes it: `out` when it was given, against the
+/// working directory it was typed in; otherwise the project's own build directory, wherever in the
+/// project the command runs.
+///
+/// The default is the project's for the reason the sources are: `by build` in `tests/` builds the
+/// whole project, and writing that into `tests/build` put a second tree where nothing looks for
+/// it — not the language server's `by/buildOutput`, which names one build directory per project,
+/// and not the next `by build` run from the root. [`by_stage::layout::default_build_directory`] is
+/// the one answer both of them read.
+fn output_dir(cwd: &Path, out: Option<&Path>) -> anyhow::Result {
+ Ok(match out {
+ Some(out) => settled_output_dir(cwd, out),
+ None => {
+ let project = ResolvedProject::discover(cwd)?;
+ settled_output_dir(
+ cwd,
+ &by_stage::layout::default_build_directory(project.root()),
+ )
+ }
+ })
+}
+
/// Where a build's output goes, settled before the project is read.
///
/// It is the one directory the project must not be read *from*: it holds a copy
@@ -1016,7 +1085,7 @@ pub(crate) struct CompileFlags {
/// actually runs natively, so it is never hidden.
pub(crate) fn cmd_compile(
files: &[PathBuf],
- output: &Path,
+ output: Option<&Path>,
flags: CompileFlags,
) -> anyhow::Result {
let CompileFlags {
@@ -1073,7 +1142,7 @@ pub(crate) fn cmd_compile(
// context that used to sit here misreported a version refusal as a missing header
let toolchain = by_build::Toolchain::probe(&python)?;
- let out_dir = settled_output_dir(&cwd, output);
+ let out_dir = output_dir(&cwd, output)?;
let mut compiled = 0usize;
let mut declined_total = 0usize;
// the output tree. `by_build` lays the artefacts out itself, so they are
@@ -1157,7 +1226,7 @@ pub(crate) fn cmd_compile(
// authored the whole tree. while `compile` wrote artefacts alone, it deleted
// the sourcemap and build record a `by build` into the same directory had
// left — and `by restage` then refused that tree, which took the language
- // server's single-file re-stage down with it
+ // server's re-stage down with it
let roots = module_roots(&db, &root);
let transpilable: Vec<(PathBuf, ruff_db::files::File)> = project
.iter()
diff --git a/crates/ty/src/cli-reference.md b/crates/ty/src/cli-reference.md
index a3ec9abc58..b0db245e93 100644
--- a/crates/ty/src/cli-reference.md
+++ b/crates/ty/src/cli-reference.md
@@ -15,13 +15,29 @@ by run main
```
resolves `main.by` in the project root, transpiles it (and every other `.by`
-file in the project) to a temporary directory, then executes
-`python -m main` from there
+file in the project) to a temporary directory, and runs `main` out of that tree
+— in the directory you invoked `by` from, the way `python -m` does, so a
+relative path the program is given and anything it reads or writes beside the
+project mean what they say
```sh
by run main --min-version 3.12
```
+### `--in-build`
+
+run in the staged tree instead of in the current directory:
+
+```sh
+by run --in-build pytest -v tests/test_calc.py
+```
+
+for a program whose arguments are the project's own files. the project is
+python only in the staged tree — `tests/test_calc.by` is `tests/test_calc.py`
+there, beside every file the build carries over unchanged, `pyproject.toml`
+included — so a path naming one of its modules resolves, and a tool reports
+paths relative to a tree laid out like the project.
+
## `by build`
transpile every `.by` file in the project to `build/`, mirroring the module
diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs
index beba858c0e..d223fe0cf9 100644
--- a/crates/ty/src/lib.rs
+++ b/crates/ty/src/lib.rs
@@ -15,7 +15,6 @@ mod version;
use std::fmt::Display;
use std::io::{BufWriter, Write};
-use std::path::Path;
use std::process::{ExitCode, Termination};
use std::sync::{Arc, Mutex};
@@ -120,15 +119,21 @@ fn run_command(command: Command) -> anyhow::Result {
args,
min_version,
python,
+ launcher,
lowering,
compiled,
+ in_build,
} => by_commands::cmd_run(
module.as_deref(),
&args,
min_version.as_deref(),
&lowering,
- compiled,
- python.as_deref(),
+ by_commands::RunFlags {
+ compiled,
+ in_build,
+ python: python.as_deref(),
+ launcher: launcher.as_deref(),
+ },
),
Command::Init {
path,
@@ -148,8 +153,8 @@ fn run_command(command: Command) -> anyhow::Result {
}
Command::Restage {
build_directory,
- file,
- } => by_commands::cmd_restage(&build_directory, &file),
+ files,
+ } => by_commands::cmd_restage(&build_directory, &files),
Command::Build {
min_version,
wheels,
@@ -163,7 +168,7 @@ fn run_command(command: Command) -> anyhow::Result {
by_commands::cmd_build(
min_version.as_deref(),
&lowering,
- out.as_deref().unwrap_or(Path::new("build")),
+ out.as_deref(),
print_manifest,
)
}
@@ -181,7 +186,7 @@ fn run_command(command: Command) -> anyhow::Result {
lowering,
} => by_commands::cmd_compile(
&files,
- &output,
+ output.as_deref(),
by_commands::CompileFlags {
verbose,
emit_c_only,
diff --git a/crates/ty/tests/by_e2e.rs b/crates/ty/tests/by_e2e.rs
index 8e99004895..bae179f899 100644
--- a/crates/ty/tests/by_e2e.rs
+++ b/crates/ty/tests/by_e2e.rs
@@ -218,7 +218,7 @@ fn compiling_one_module_leaves_the_others_importable() {
fn a_compile_leaves_a_build_tree_readable() {
// `compile` and `build` write to the same directory by design. while
// `compile` wrote artefacts alone it took the sourcemap and the build record
- // with it, and `by restage` — the language server's single-file re-stage —
+ // with it, and `by restage` — the language server's re-stage —
// then refused the tree for having no `_by_build.json`, so a compile silently
// disabled the editor plugin against it
let dir = resource_project("by_cli_compile_then_restage");
@@ -798,6 +798,110 @@ fn run_names_the_by_source_for_file_and_argv() {
);
}
+/// A project whose entry module reports the directory it runs in and whether it
+/// can see the project's own files from there, at `app/calc.py` (python only in
+/// the staged tree) and `pyproject.toml` (carried over into it unchanged).
+fn project_reporting_where_it_runs(dir: &Path) {
+ fs::write(
+ dir.join("pyproject.toml"),
+ "[project]\nname = \"demo\"\nversion = \"0.1.0\"\n",
+ )
+ .unwrap();
+ let package = dir.join("app");
+ fs::create_dir_all(&package).unwrap();
+ fs::write(package.join("__init__.by"), "").unwrap();
+ fs::write(
+ package.join("calc.by"),
+ "def add(a: int, b: int) -> int:\n return a + b\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.join("main.by"),
+ "import os\n\nprint(os.getcwd())\nprint(os.path.exists('app/calc.py'))\nprint(os.path.exists('pyproject.toml'))\n",
+ )
+ .unwrap();
+ fs::create_dir_all(dir.join("tools")).unwrap();
+}
+
+/// the directory the program runs in is the one `by` was invoked from, not the
+/// project root and not the tree it staged: a relative path the program was
+/// given, and anything it reads or writes beside the project, mean what they
+/// meant when they were written
+#[test]
+fn run_runs_in_the_directory_it_was_started_in() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_reporting_where_it_runs(dir.path());
+ let elsewhere = dir.path().join("tools");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["run", "main"])
+ .current_dir(&elsewhere)
+ .output()
+ .expect("failed to spawn by");
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success(),
+ "by run failed:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let lines: Vec<&str> = stdout.lines().collect();
+ assert_eq!(
+ Path::new(lines[0]).canonicalize().ok(),
+ elsewhere.canonicalize().ok(),
+ "ran in {} rather than where it was started:\n{stdout}",
+ lines[0]
+ );
+ assert_eq!(
+ &lines[1..],
+ ["False", "False"],
+ "the caller's directory holds neither, so neither is visible:\n{stdout}"
+ );
+}
+
+/// `--in-build` runs the program in the tree `by run` staged, which is where the
+/// project is python: a test runner handed `tests/test_x.py` finds a file at all
+/// only from inside it, and reports what it collects against a tree laid out
+/// like the project — configuration and carried-over files included
+#[test]
+fn run_in_build_runs_in_the_tree_it_staged() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_reporting_where_it_runs(dir.path());
+ let elsewhere = dir.path().join("tools");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["run", "--in-build", "main"])
+ .current_dir(&elsewhere)
+ .output()
+ .expect("failed to spawn by");
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(
+ output.status.success(),
+ "by run --in-build failed:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let lines: Vec<&str> = stdout.lines().collect();
+ assert_ne!(
+ Path::new(lines[0]).canonicalize().ok(),
+ elsewhere.canonicalize().ok(),
+ "ran where it was started rather than in the staged tree:\n{stdout}"
+ );
+ assert_ne!(
+ Path::new(lines[0]).canonicalize().ok(),
+ dir.path().canonicalize().ok(),
+ "ran in the project rather than in the staged tree:\n{stdout}"
+ );
+ assert_eq!(
+ &lines[1..],
+ ["True", "True"],
+ "the staged tree holds the transpiled module and the project's \
+ configuration beside it:\n{stdout}"
+ );
+}
+
/// `multiprocessing`'s spawn start method — the default on macos and windows —
/// reads `__main__.__spec__.name` to tell the child what to re-import, and falls
/// back to running `__file__` as a *path* when there is none. `__file__` is a
@@ -5652,6 +5756,87 @@ fn run_from_a_subdirectory_is_still_the_project() {
);
}
+/// a launcher is handed the interpreter the project's discovery chose, and is run once — for the
+/// program, not for the version probe
+#[cfg(unix)]
+#[test]
+fn run_starts_the_program_through_a_launcher_with_the_interpreter_it_discovered() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let environment = python_environment(&dir.path().join(".venv"));
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ format!(
+ "[project]\nname = \"demo\"\nversion = \"0.1.0\"\n{}",
+ environment.requires_python()
+ ),
+ )
+ .unwrap();
+ fs::write(dir.path().join("main.by"), REPORTS_ITS_INTERPRETER).unwrap();
+ let seen = dir.path().join("seen");
+ let launcher = dir.path().join("launcher");
+ fs::write(
+ &launcher,
+ format!(
+ "#!/bin/sh\n\
+ printf 'invoked\\n' >> '{seen}'\n\
+ for arg in \"$@\"; do printf 'arg %s\\n' \"$arg\" >> '{seen}'; done\n\
+ exec \"$@\"\n",
+ seen = seen.display()
+ ),
+ )
+ .unwrap();
+ fs::set_permissions(&launcher, fs::Permissions::from_mode(0o755)).unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .arg("run")
+ .arg("--launcher")
+ .arg(&launcher)
+ .args(["main", "extra"])
+ .current_dir(dir.path())
+ .env_remove("PYTHON")
+ .env_remove("VIRTUAL_ENV")
+ .output()
+ .expect("failed to spawn by");
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(output.status.success(), "by run failed:\n{stderr}");
+ assert!(
+ environment.ran_it(&stdout),
+ "the program ran on the project's environment, launcher or not:\n{stdout}\n{stderr}"
+ );
+ let seen = fs::read_to_string(&seen).expect("the launcher ran");
+ assert_eq!(
+ seen.matches("invoked").count(),
+ 1,
+ "the launcher is run for the program alone:\n{seen}"
+ );
+ let arguments: Vec<&str> = seen
+ .lines()
+ .filter_map(|line| line.strip_prefix("arg "))
+ .collect();
+ assert!(
+ arguments
+ .first()
+ .is_some_and(|python| environment.ran_it(python)),
+ "the launcher's first argument is the interpreter discovery chose:\n{seen}"
+ );
+ assert!(
+ arguments
+ .get(1)
+ .is_some_and(|runner| runner.ends_with("_by_runner.py")),
+ "then the runner:\n{seen}"
+ );
+ assert_eq!(
+ arguments.get(2..),
+ Some(&["main", "extra"][..]),
+ "then the program:\n{seen}"
+ );
+}
+
#[test]
fn build_from_a_subdirectory_builds_the_project() {
let dir = tempfile::tempdir().expect("tempdir");
@@ -5675,13 +5860,52 @@ fn build_from_a_subdirectory_builds_the_project() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "by build failed:\n{stderr}");
+ // the tree the language server's `by/buildOutput` names for this project, which is the
+ // project's `build/` wherever the build was run from
assert!(
- elsewhere
+ dir.path()
.join("build")
.join("app")
.join("__init__.py")
.exists(),
- "the module tree is the project's, not the caller's:\n{stderr}"
+ "the module tree is the project's, and so is where it is written:\n{stderr}"
+ );
+ assert!(
+ !elsewhere.join("build").exists(),
+ "a build run from a subdirectory wrote a second tree there:\n{stderr}"
+ );
+}
+
+/// `--out` is a path the caller typed, so it is the caller's directory it is relative to — only the
+/// default belongs to the project
+#[test]
+fn an_explicit_output_directory_is_relative_to_where_the_build_runs() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[project]\nname = \"demo\"\nversion = \"0.1.0\"\n",
+ )
+ .unwrap();
+ fs::write(dir.path().join("main.by"), "x = 1\n").unwrap();
+ let elsewhere = dir.path().join("tools");
+ fs::create_dir_all(&elsewhere).unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["build", "--out", "here"])
+ .current_dir(&elsewhere)
+ .output()
+ .expect("failed to spawn by");
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(output.status.success(), "by build failed:\n{stderr}");
+ assert!(
+ elsewhere.join("here").join("main.py").exists(),
+ "`--out` names a directory relative to where it was typed:\n{stderr}"
+ );
+ assert!(
+ !dir.path().join("build").exists(),
+ "an explicit `--out` still wrote the default tree:\n{stderr}"
);
}
diff --git a/crates/ty/tests/by_restage.rs b/crates/ty/tests/by_restage.rs
index 954d9f613e..5468ed94f3 100644
--- a/crates/ty/tests/by_restage.rs
+++ b/crates/ty/tests/by_restage.rs
@@ -1,4 +1,4 @@
-//! `by restage` — one file's slot in a build tree that already exists.
+//! `by restage` — the slots of an edit's files in a build tree that already exists.
//!
//! Driven through the real binary rather than the library, because the property that matters is
//! about two commands agreeing: what `by build` wrote into the tree, and what `by restage` says
@@ -31,21 +31,54 @@ fn build(dir: &Path) {
assert!(status.success(), "`by build` failed");
}
-fn restage(dir: &Path, file: &str) -> (bool, serde_json::Value) {
+fn restage_all(dir: &Path, files: &[&str]) -> (bool, serde_json::Value) {
let out = Command::new(env!("CARGO_BIN_EXE_by"))
- .args(["restage", "build", file])
+ .args(["restage", "build"])
+ .args(files)
.current_dir(dir)
.output()
.expect("`by restage` should run");
let json = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
- "`by restage {file}` printed something that is not json: {e}\n{}",
+ "`by restage {files:?}` printed something that is not json: {e}\n{}",
String::from_utf8_lossy(&out.stdout)
)
});
(out.status.success(), json)
}
+/// A set of one: the file's own slot, and the set's map beside it.
+fn restage(dir: &Path, file: &str) -> (bool, Slot) {
+ let (ok, answer) = restage_all(dir, &[file]);
+ (ok, Slot(answer))
+}
+
+/// One file's answer, read through the set it came back in.
+struct Slot(serde_json::Value);
+
+impl Slot {
+ /// the file's own field, or the set's `sourcemap`
+ fn get(&self, field: &str) -> &serde_json::Value {
+ match field {
+ "sourcemap" => &self.0["sourcemap"],
+ _ => &self.0["files"][0][field],
+ }
+ }
+
+ /// the first reason the set was refused
+ fn refused(&self) -> &str {
+ self.0["refusals"][0]["refused"]
+ .as_str()
+ .unwrap_or_else(|| panic!("not a refusal: {}", self.0))
+ }
+}
+
+impl std::fmt::Display for Slot {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
/// A re-stage is handed no stamps, so it can only reproduce the build's if the
/// build wrote them down.
///
@@ -92,8 +125,8 @@ build-stamps = true
let (ok, answer) = restage(dir.path(), "main.by");
assert!(ok, "an unedited file should re-stage: {answer}");
- assert_eq!(answer["content"].as_str().unwrap(), on_disk);
- assert_eq!(answer["changed"].as_bool(), Some(false));
+ assert_eq!(answer.get("content").as_str().unwrap(), on_disk);
+ assert_eq!(answer.get("changed").as_bool(), Some(false));
}
/// **The property the whole design rests on.**
@@ -115,8 +148,8 @@ fn restaging_a_file_nobody_edited_reproduces_the_build_exactly() {
let (ok, answer) = restage(dir.path(), "main.by");
assert!(ok, "an unedited file should re-stage: {answer}");
- assert_eq!(answer["content"].as_str().unwrap(), on_disk);
- assert_eq!(answer["changed"].as_bool(), Some(false));
+ assert_eq!(answer.get("content").as_str().unwrap(), on_disk);
+ assert_eq!(answer.get("changed").as_bool(), Some(false));
}
/// The path a caller writes to, and the key `_by_sourcemap.py` uses, have to be the same path — a
@@ -129,7 +162,7 @@ fn the_generated_path_is_absolute_even_for_a_relative_build_directory() {
build(dir.path());
let (_, answer) = restage(dir.path(), "main.by");
- let generated = Path::new(answer["generated"].as_str().unwrap());
+ let generated = Path::new(answer.get("generated").as_str().unwrap());
assert!(
generated.is_absolute(),
@@ -160,10 +193,17 @@ fn an_edited_source_comes_back_changed_with_a_rewritten_map() {
let (ok, answer) = restage(dir.path(), "main.by");
assert!(ok, "an edited file that checks should re-stage: {answer}");
- assert_eq!(answer["changed"].as_bool(), Some(true));
- assert!(answer["content"].as_str().unwrap().contains("return 42"));
+ assert_eq!(answer.get("changed").as_bool(), Some(true));
+ assert!(
+ answer
+ .get("content")
+ .as_str()
+ .unwrap()
+ .contains("return 42")
+ );
- let map = answer["sourcemap"]
+ let map = answer
+ .get("sourcemap")
.as_str()
.expect("a transpiled file's re-stage rewrites the map");
assert!(map.contains("SOURCEMAP"), "{map}");
@@ -188,11 +228,13 @@ fn a_source_saved_without_a_trailing_newline_re_stages_a_whole_line_table() {
std::fs::write(dir.path().join("main.by"), source).unwrap();
let (ok, answer) = restage(dir.path(), "main.by");
assert!(ok, "an edited file that checks should re-stage: {answer}");
- let map = answer["sourcemap"]
+ let map = answer
+ .get("sourcemap")
.as_str()
.expect("a transpiled file's re-stage rewrites the map")
.to_owned();
- let content = answer["content"]
+ let content = answer
+ .get("content")
.as_str()
.expect("bytes to write")
.to_owned();
@@ -237,11 +279,11 @@ fn a_hand_written_python_is_its_own_bytes_and_has_no_map_entry() {
assert!(ok, "a copied python file should re-stage: {answer}");
assert_eq!(
- answer["content"].as_str().unwrap(),
+ answer.get("content").as_str().unwrap(),
"def h() -> int:\n return 3\n"
);
- assert_eq!(answer["changed"].as_bool(), Some(true));
- assert!(answer["sourcemap"].is_null(), "{answer}");
+ assert_eq!(answer.get("changed").as_bool(), Some(true));
+ assert!(answer.get("sourcemap").is_null(), "{answer}");
}
/// A file that does not check must not reach a running program: the transpiler would emit for a
@@ -262,14 +304,8 @@ fn a_source_that_does_not_check_is_refused_with_the_reasons() {
let (ok, answer) = restage(dir.path(), "main.by");
assert!(!ok, "a refusal exits non-zero so a script can read it");
- assert!(
- answer["refused"]
- .as_str()
- .unwrap()
- .contains("does not check"),
- "{answer}"
- );
- let diagnostics = answer["diagnostics"].as_array().unwrap();
+ assert!(answer.refused().contains("does not check"), "{answer}");
+ let diagnostics = answer.0["refusals"][0]["diagnostics"].as_array().unwrap();
assert!(
!diagnostics.is_empty(),
"a refusal for the checker carries what it said"
@@ -289,13 +325,7 @@ fn a_directory_that_is_not_a_build_is_refused() {
let (ok, answer) = restage(dir.path(), "main.by");
assert!(!ok);
- assert!(
- answer["refused"]
- .as_str()
- .unwrap()
- .contains("_by_build.json"),
- "{answer}"
- );
+ assert!(answer.refused().contains("_by_build.json"), "{answer}");
}
/// The tree records which `by` wrote it because only that `by` can promise the same bytes. A tree
@@ -315,8 +345,191 @@ fn a_tree_built_by_another_by_is_refused() {
let (ok, answer) = restage(dir.path(), "main.by");
assert!(!ok);
+ assert!(answer.refused().contains("rebuild"), "{answer}");
+}
+
+/// A project with two `.by` modules, so one edit can touch both.
+fn write_two_module_project(dir: &Path) {
+ write_project(dir);
+ std::fs::write(dir.join("other.by"), "def other() -> int:\n return 5\n").unwrap();
+}
+
+/// Put an answer's bytes and its map into the tree, the way a client does.
+fn write_answer(dir: &Path, answer: &serde_json::Value) {
+ for file in answer["files"].as_array().unwrap() {
+ std::fs::write(
+ file["generated"].as_str().unwrap(),
+ file["content"].as_str().unwrap(),
+ )
+ .unwrap();
+ }
+ if let Some(map) = answer["sourcemap"].as_str() {
+ std::fs::write(dir.join("build/_by_sourcemap.py"), map).unwrap();
+ }
+}
+
+/// The keys of both tables of a map, in the order the map holds them.
+fn map_keys(map: &str) -> Vec {
+ map.lines()
+ .filter_map(|line| line.strip_prefix(" \""))
+ .map(|line| line.split('"').next().unwrap().to_owned())
+ .collect()
+}
+
+/// **Why the set is one request.** Two files edited together share one `_by_sourcemap.py`, and
+/// the map that comes back has to carry both of their entries moved. Answered one file at a time,
+/// each answer's map held the tree's map plus that file alone, so writing them in turn kept only
+/// the last file's line table — and every other breakpoint was then re-armed against a table
+/// describing code the tree no longer held.
+#[test]
+fn an_edit_to_two_modules_comes_back_with_one_map_describing_both() {
+ let dir = tempfile::tempdir().unwrap();
+ write_two_module_project(dir.path());
+ build(dir.path());
+ let before = std::fs::read_to_string(dir.path().join("build/_by_sourcemap.py")).unwrap();
+
+ std::fs::write(
+ dir.path().join("main.by"),
+ "def go() -> int:\n return 42\nprint(go())\n",
+ )
+ .unwrap();
+ std::fs::write(
+ dir.path().join("other.by"),
+ "\n\ndef other() -> int:\n return 6\n",
+ )
+ .unwrap();
+ let (ok, answer) = restage_all(dir.path(), &["main.by", "other.by"]);
+ assert!(ok, "two edited files that check should re-stage: {answer}");
+
+ let files = answer["files"].as_array().unwrap();
+ assert_eq!(files.len(), 2, "{answer}");
+ let map = answer["sourcemap"].as_str().expect("both entries moved");
+
+ for file in files {
+ assert_eq!(file["changed"].as_bool(), Some(true), "{file}");
+ let py = file["pyDigest"].as_str().unwrap();
+ let by = file["byDigest"].as_str().unwrap();
+ assert!(
+ !before.contains(py) && map.contains(py) && map.contains(by),
+ "the one map carries this file's new digests:\n{map}\n{file}"
+ );
+ }
+ // and nothing else about the map moved: both tables hold the same keys, in the same order,
+ // as the map the build wrote
+ assert_eq!(map_keys(&before), map_keys(map));
+}
+
+/// Writing what came back makes the tree what the edit describes: asked again, every file is
+/// already there and the map already is the one on disk.
+#[test]
+fn writing_a_sets_answer_leaves_nothing_left_to_restage() {
+ let dir = tempfile::tempdir().unwrap();
+ write_two_module_project(dir.path());
+ build(dir.path());
+
+ std::fs::write(
+ dir.path().join("main.by"),
+ "def go() -> int:\n return 42\nprint(go())\n",
+ )
+ .unwrap();
+ std::fs::write(
+ dir.path().join("other.by"),
+ "def other() -> int:\n return 6\n",
+ )
+ .unwrap();
+ let (ok, answer) = restage_all(dir.path(), &["main.by", "other.by"]);
+ assert!(ok, "{answer}");
+ write_answer(dir.path(), &answer);
+
+ let (ok, again) = restage_all(dir.path(), &["main.by", "other.by"]);
+ assert!(ok, "{again}");
+ for file in again["files"].as_array().unwrap() {
+ assert_eq!(file["changed"].as_bool(), Some(false), "{file}");
+ }
+ assert!(again["sourcemap"].is_null(), "{again}");
+}
+
+/// One file of the set that does not check refuses the set, and names that file — the other
+/// file's bytes are not handed back to be written on their own.
+#[test]
+fn one_file_that_does_not_check_refuses_the_whole_set_by_name() {
+ let dir = tempfile::tempdir().unwrap();
+ write_two_module_project(dir.path());
+ build(dir.path());
+
+ std::fs::write(
+ dir.path().join("main.by"),
+ "def go() -> int:\n return 42\nprint(go())\n",
+ )
+ .unwrap();
+ std::fs::write(
+ dir.path().join("other.by"),
+ "def other() -> int:\n return \"not an int\"\n",
+ )
+ .unwrap();
+ let (ok, answer) = restage_all(dir.path(), &["main.by", "other.by"]);
+
+ assert!(!ok, "{answer}");
+ assert!(answer.get("files").is_none(), "{answer}");
+ let refusals = answer["refusals"].as_array().unwrap();
+ assert_eq!(
+ refusals.len(),
+ 1,
+ "only the file that does not check: {answer}"
+ );
assert!(
- answer["refused"].as_str().unwrap().contains("rebuild"),
+ refusals[0]["file"].as_str().unwrap().ends_with("other.by"),
"{answer}"
);
+ assert!(
+ !refusals[0]["diagnostics"].as_array().unwrap().is_empty(),
+ "{answer}"
+ );
+}
+
+/// A `.by` and a hand-written `.py` edited together: the copied file comes back as its own bytes,
+/// and the map moves only for the file it describes.
+#[test]
+fn a_set_holding_a_copied_file_moves_the_map_only_for_the_transpiled_one() {
+ let dir = tempfile::tempdir().unwrap();
+ write_project(dir.path());
+ build(dir.path());
+
+ std::fs::write(
+ dir.path().join("main.by"),
+ "def go() -> int:\n return 42\nprint(go())\n",
+ )
+ .unwrap();
+ std::fs::write(
+ dir.path().join("helper.py"),
+ "def h() -> int:\n return 3\n",
+ )
+ .unwrap();
+ let (ok, answer) = restage_all(dir.path(), &["helper.py", "main.by"]);
+
+ assert!(ok, "{answer}");
+ let files = answer["files"].as_array().unwrap();
+ assert!(
+ files[0]["source"].as_str().unwrap().ends_with("helper.py"),
+ "in the order asked"
+ );
+ assert_eq!(files[0]["content"], "def h() -> int:\n return 3\n");
+ assert!(
+ answer["sourcemap"]
+ .as_str()
+ .unwrap()
+ .contains(files[1]["pyDigest"].as_str().unwrap())
+ );
+}
+
+/// The same file named twice is one slot: two answers for it would be two writes to one file.
+#[test]
+fn a_file_named_twice_is_answered_once() {
+ let dir = tempfile::tempdir().unwrap();
+ write_project(dir.path());
+ build(dir.path());
+
+ let (ok, answer) = restage_all(dir.path(), &["main.by", "./main.by"]);
+ assert!(ok, "{answer}");
+ assert_eq!(answer["files"].as_array().unwrap().len(), 1, "{answer}");
}
diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs
index 7db0ead8d5..3cf0b1e711 100644
--- a/crates/ty/tests/file_watching.rs
+++ b/crates/ty/tests/file_watching.rs
@@ -2040,7 +2040,29 @@ mod unix {
bar_baz_text = bar_baz_text.as_str()
);
- case.assert_indexed_project_files([patched_bar_baz_file]);
+ if cfg!(target_os = "linux") {
+ // Known bug: the index depends on which path notify reports. Discovery skips symlinked
+ // directories, so initially only `project/patched/bar/baz.py` is indexed. Resolving
+ // `bar.baz` creates a separate `File` for `project/bar/baz.py`, outside the index.
+ //
+ // notify uses different watcher backends across operating systems. On Linux, it follows
+ // the symlink and registers both directory paths. Inotify returns the same watch
+ // descriptor for both, and notify retains the last registered path. The recursive walk
+ // doesn't sort directory entries, so filesystem iteration order determines which path
+ // wins. That order can differ between filesystems and runs, even on the same OS.
+ //
+ // An event for the original path updates its `File` and leaves the index unchanged.
+ // An event for the symlinked path updates that `File` and adds it to the index, leaving
+ // both paths indexed but the original `File`'s revision stale. Accept either path (or
+ // both) until indexing agrees with discovery regardless of the reported path.
+ let files = case.db().project().files(case.db());
+ assert!(
+ files.contains(patched_bar_baz_file) || files.contains(baz_file),
+ "Expected the project to contain the original or symlinked file"
+ );
+ } else {
+ case.assert_indexed_project_files([patched_bar_baz_file]);
+ }
Ok(())
}
diff --git a/crates/ty_ide/Cargo.toml b/crates/ty_ide/Cargo.toml
index 619e585be4..d09a291452 100644
--- a/crates/ty_ide/Cargo.toml
+++ b/crates/ty_ide/Cargo.toml
@@ -48,6 +48,7 @@ strum = { workspace = true }
strum_macros = { workspace = true }
toml_parser = { workspace = true }
tracing = { workspace = true }
+unicode-width = { workspace = true }
[dev-dependencies]
ruff_python_parser = { workspace = true }
diff --git a/crates/ty_ide/src/alignment.rs b/crates/ty_ide/src/alignment.rs
index 1a5661455f..81e6270151 100644
--- a/crates/ty_ide/src/alignment.rs
+++ b/crates/ty_ide/src/alignment.rs
@@ -20,6 +20,12 @@
//! draws a hint only while a key is held — so a width computed here would be wrong the moment the
//! key came up. this module reports the grouping and the room the author left; the client turns
//! that into pixels
+//!
+//! a column is counted the way a fixed-width editor lays the line out: a wide character takes two
+//! columns, a combining mark none, and a tab reaches the next multiple of the tab size — which is
+//! the client's setting, so the client says what it is
+
+use std::num::NonZeroU32;
use ruff_db::PythonFile;
use ruff_db::parsed::parsed_module;
@@ -28,8 +34,10 @@ use ruff_python_ast::visitor::source_order::{
SourceOrderVisitor, TraversalSignal, walk_body, walk_node,
};
use ruff_python_ast::{AnyNodeRef, Stmt};
+use ruff_python_trivia::tab_offset_u32;
use ruff_source_file::LineRanges;
use ruff_text_size::{Ranged, TextRange, TextSize};
+use unicode_width::UnicodeWidthChar;
use crate::Db;
use crate::inlay_hints::untyped_declaration_value;
@@ -55,6 +63,19 @@ pub struct AlignmentMember {
/// and since the column no longer has to be padded to be a column, it is not this module's
/// either
pub gap_end: TextSize,
+
+ /// the display column [`Self::gap_start`] is at — how far along its line a fixed-width editor
+ /// with the tab size given to [`alignment_groups`] draws it
+ ///
+ /// reported alongside the offset because a client lays hints out in columns, and a column is
+ /// not something it can read back off the offset without repeating this measurement
+ pub gap_start_column: u32,
+
+ /// the display column of the `=`, which every member of a group shares
+ ///
+ /// the gap is spaces only, so this less [`Self::gap_start_column`] is also its length in
+ /// characters
+ pub gap_end_column: u32,
}
/// assignments the author put in one column, reported together because they have to move together
@@ -96,10 +117,13 @@ pub struct AlignmentGroup {
///
/// a group is reported whole even when only one of its lines is in `range`: the column is a
/// property of every member at once, so half a group would be sized against the wrong maximum
+///
+/// `tab_size` is the number of columns between the tab stops of the editor the lines are shown in
pub fn alignment_groups(
db: &dyn Db,
file: PythonFile<'_>,
range: TextRange,
+ tab_size: NonZeroU32,
) -> Vec {
let parsed = parsed_module(db, file).load(db);
let source = source_text(db, file.file(db));
@@ -108,6 +132,7 @@ pub fn alignment_groups(
let mut visitor = AlignmentVisitor {
source,
range,
+ tab_size,
groups: Vec::new(),
};
walk_node(&mut visitor, AnyNodeRef::from(parsed.syntax()));
@@ -152,6 +177,9 @@ struct AlignmentVisitor<'a> {
/// the span the caller asked about, used to skip nodes it does not reach
range: TextRange,
+ /// the columns between tab stops, see [`display_column`]
+ tab_size: NonZeroU32,
+
groups: Vec,
}
@@ -204,7 +232,7 @@ impl AlignmentVisitor<'_> {
let joins = match (run.first(), previous_end) {
(Some(first), Some(previous_end)) => {
- self.column_of(first.gap_end) == self.column_of(member.gap_end)
+ first.gap_end_column == member.gap_end_column
&& !self.blank_line_between(previous_end, stmt.start())
}
_ => false,
@@ -266,16 +294,23 @@ impl AlignmentVisitor<'_> {
_ => return Alignment::Breaks,
};
match self.equals_after(gap_start) {
- Some(gap_end) => Alignment::Member(AlignmentMember { gap_start, gap_end }),
+ Some(gap_end) => Alignment::Member(AlignmentMember {
+ gap_start,
+ gap_end,
+ gap_start_column: display_column(self.source, gap_start, self.tab_size),
+ gap_end_column: display_column(self.source, gap_end, self.tab_size),
+ }),
None => Alignment::Breaks,
}
}
/// the `=` that follows `from` with nothing but spaces in between, if that is what follows
///
- /// spaces only, and deliberately: a tab is not a column anyone can agree on, and a newline or a
- /// `\` means the `=` is on another line, where it is aligned with nothing. each of those makes
- /// the statement unalignable rather than merely unpadded
+ /// spaces only, and deliberately. the gap is room a hint spends column for column, and a tab
+ /// is not that: how far it reaches depends on where it starts, so a hint drawn ahead of it
+ /// changes how much room it leaves. a newline or a `\` means the `=` is on another line, where
+ /// it is aligned with nothing. each of those makes the statement unalignable rather than merely
+ /// unpadded
fn equals_after(&self, from: TextSize) -> Option {
let rest = self.source.get(from.to_usize()..)?;
let spaces = rest.len() - rest.trim_start_matches(' ').len();
@@ -285,25 +320,6 @@ impl AlignmentVisitor<'_> {
Some(from + TextSize::try_from(spaces).ok()?)
}
- /// how far into its line an offset is, in characters
- ///
- /// characters rather than bytes, because bytes do not answer the question being asked. `é = 1`
- /// and `ab = 1` spend the same number of bytes reaching their `=` and put it in different
- /// columns, so counting bytes would call those two lines aligned — and would miss two that
- /// really are
- ///
- /// characters rather than rendered width, because width is the client's question — an editor
- /// draws its own glyphs, and in a proportional font even two ASCII letters are not the same
- /// width. all that is decided here is whether the author put two `=` in the same place
- fn column_of(&self, offset: TextSize) -> usize {
- let line_start = self.source.line_start(offset);
- self.source
- .get(line_start.to_usize()..offset.to_usize())
- .unwrap_or_default()
- .chars()
- .count()
- }
-
/// whether the author left an empty line between two statements
///
/// the first newline is the one that ends the earlier statement. any *complete* line after it
@@ -327,6 +343,30 @@ impl AlignmentVisitor<'_> {
}
}
+/// how far along its line a fixed-width editor draws `offset`
+///
+/// neither bytes nor characters answer that. `é = 1` and `ab = 1` spend the same number of bytes
+/// reaching their `=` and put it in different columns; `名前 = 1` and `abcd = 1` spend different
+/// numbers of characters and put it in the same one. so each character counts for its unicode
+/// width — two for a wide character, none for a combining mark — and a tab advances to the next
+/// multiple of `tab_size`, the same measure `ruff_linter`'s `LineWidthBuilder` gives a line
+///
+/// still columns rather than pixels: in a proportional font even two ASCII letters differ, and
+/// that is the client's to draw. what is decided here is whether the author put two `=` in the
+/// same place on a fixed grid
+fn display_column(source: &str, offset: TextSize, tab_size: NonZeroU32) -> u32 {
+ let line_start = source.line_start(offset);
+ source
+ .get(line_start.to_usize()..offset.to_usize())
+ .unwrap_or_default()
+ .chars()
+ .fold(0, |column, character| match character {
+ '\t' => column + tab_offset_u32(column, tab_size.get()),
+ #[expect(clippy::cast_possible_truncation)]
+ character => column + character.width().unwrap_or(0) as u32,
+ })
+}
+
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
@@ -341,20 +381,35 @@ mod tests {
/// rendered rather than asserted as offsets because what matters about this analysis is
/// which lines ended up together and how much room each has, and a list of byte offsets
/// hides both
+ ///
+ /// with tab stops every four columns
fn alignment_groups(&self) -> String {
+ self.alignment_groups_with_tab_size(4)
+ }
+
+ /// the same, with tab stops every `tab_size` columns
+ fn alignment_groups_with_tab_size(&self, tab_size: u32) -> String {
let source = self.cursor.source.as_str();
- self.alignment_groups_in(TextRange::new(TextSize::new(0), TextSize::of(source)))
+ self.alignment_groups_in(
+ TextRange::new(TextSize::new(0), TextSize::of(source)),
+ tab_size,
+ )
}
/// the same, for a caller asking about only part of the file
- fn alignment_groups_in(&self, range: TextRange) -> String {
+ ///
+ /// each line is drawn with its tabs expanded, and the gap under it at the columns the
+ /// analysis reported, so a column it got wrong shows up as a gap drawn in the wrong place
+ fn alignment_groups_in(&self, range: TextRange, tab_size: u32) -> String {
use std::fmt::Write;
let source = self.cursor.source.as_str();
+ let tab_size = NonZeroU32::new(tab_size).unwrap();
let groups = alignment_groups(
&self.db,
self.program_file(self.cursor.file).python_file(&self.db),
range,
+ tab_size,
);
if groups.is_empty() {
return "no groups".to_string();
@@ -370,9 +425,23 @@ mod tests {
.next()
.unwrap_or_default()
.trim_end_matches('\r');
- let lead = (member.gap_start - start).to_usize();
- let gap = (member.gap_end - member.gap_start).to_usize();
- writeln!(out, " {line}").unwrap();
+ // the same walk `display_column` makes, character by character: an oracle
+ // that measured the line another way could agree with a wrong answer
+ let mut expanded = String::new();
+ let mut width = 0u32;
+ for character in line.chars() {
+ if character == '\t' {
+ let stop = tab_offset_u32(width, tab_size.get());
+ expanded.extend(std::iter::repeat_n(' ', stop as usize));
+ width += stop;
+ } else {
+ expanded.push(character);
+ width += u32::try_from(character.width().unwrap_or(0)).unwrap();
+ }
+ }
+ let lead = member.gap_start_column as usize;
+ let gap = (member.gap_end_column - member.gap_start_column) as usize;
+ writeln!(out, " {expanded}").unwrap();
writeln!(
out,
" {}{} gap {gap}",
@@ -698,7 +767,7 @@ basdf = 1
");
}
- /// a column is where a reader sees the `=`, so it is counted in characters. these two spend the
+ /// a column is where a reader sees the `=`, so it is not counted in bytes. these two spend the
/// same number of bytes reaching their `=` and put it in different columns
#[test]
fn a_column_is_not_a_byte_count() {
@@ -724,12 +793,94 @@ abcde = 2
assert_snapshot!(test.alignment_groups(), @r"
group 1
é = 1
- ----- gap 5
+ ----- gap 5
abcde = 2
- gap 1
");
}
+ /// a wide character takes two columns of the line, so these two put their `=` in one column
+ /// while spending different numbers of characters to get there
+ #[test]
+ fn a_wide_character_takes_two_columns() {
+ let test = cursor_test(
+ "\
+名前 = 1
+abcd = 2
+",
+ );
+ assert_snapshot!(test.alignment_groups(), @r"
+ group 1
+ 名前 = 1
+ - gap 1
+ abcd = 2
+ - gap 1
+ ");
+ }
+
+ /// and these spend the same number of characters and put their `=` in different columns
+ #[test]
+ fn a_column_is_not_a_character_count() {
+ let test = cursor_test(
+ "\
+名前 = 1
+abcd = 2
+",
+ );
+ assert_snapshot!(test.alignment_groups(), @"no groups");
+ }
+
+ /// a tab before the gap reaches the next tab stop, so where the `=` lands depends on it
+ #[test]
+ fn a_tab_in_the_target_reaches_the_next_tab_stop() {
+ let test = cursor_test(
+ "\
+x:\tint = 1
+abcdefg = 2
+",
+ );
+ assert_snapshot!(test.alignment_groups(), @r"
+ group 1
+ x: int = 1
+ - gap 1
+ abcdefg = 2
+ - gap 1
+ ");
+ }
+
+ /// and the tab stop is the editor's, so the same lines with stops every eight columns put the
+ /// first `=` four columns further out
+ #[test]
+ fn the_tab_stop_is_the_editors() {
+ let test = cursor_test(
+ "\
+x:\tint = 1
+abcdefg = 2
+",
+ );
+ assert_snapshot!(test.alignment_groups_with_tab_size(8), @"no groups");
+ }
+
+ /// indentation is measured with the rest of the line, so a suite indented with tabs lines up
+ /// the same as one indented with spaces
+ #[test]
+ fn a_suite_indented_with_tabs() {
+ let test = cursor_test(
+ "\
+def f():
+\ta = 1
+\tbasdf = 2
+",
+ );
+ assert_snapshot!(test.alignment_groups(), @r"
+ group 1
+ a = 1
+ ----- gap 5
+ basdf = 2
+ - gap 1
+ ");
+ }
+
/// a declaration with no value writes nothing in the column, so the block reads through it
#[test]
fn a_declaration_without_a_value_does_not_end_the_block() {
@@ -790,7 +941,8 @@ dddddd = 3
assert_snapshot!(test.alignment_groups(), @"no groups");
}
- /// a tab is not a column anyone can agree on
+ /// a tab in the gap reaches further or less far depending on where the hint ahead of it ends, so
+ /// it is not room a hint can spend
#[test]
fn a_tab_before_the_equals_ends_the_block() {
let test = cursor_test(
@@ -825,7 +977,7 @@ a = 1
basdf = 1
",
);
- assert_snapshot!(test.alignment_groups_in(test.line(1)), @r"
+ assert_snapshot!(test.alignment_groups_in(test.line(1), 4), @r"
group 1
a = 1
----- gap 5
@@ -845,7 +997,7 @@ def f():
basdf = 1
",
);
- assert_snapshot!(test.alignment_groups_in(test.line(1)), @r"
+ assert_snapshot!(test.alignment_groups_in(test.line(1), 4), @r"
group 1
a = 1
----- gap 5
@@ -866,7 +1018,7 @@ cc = 2
dddde = 3
",
);
- assert_snapshot!(test.alignment_groups_in(test.line(3)), @r"
+ assert_snapshot!(test.alignment_groups_in(test.line(3), 4), @r"
group 1
cc = 2
---- gap 4
diff --git a/crates/ty_ide/src/doc_highlights.rs b/crates/ty_ide/src/doc_highlights.rs
index 5f3116ecc4..1db6b55d59 100644
--- a/crates/ty_ide/src/doc_highlights.rs
+++ b/crates/ty_ide/src/doc_highlights.rs
@@ -1,17 +1,32 @@
use crate::goto::find_goto_target;
use crate::references::{ReferencesMode, references};
-use crate::{Db, ReferenceTarget};
+use crate::syntax_outline::keyword_family;
+use crate::{Db, ReferenceKind, ReferenceTarget};
use ruff_text_size::TextSize;
use ty_python_core::ProgramFile;
use ty_python_semantic::SemanticModel;
/// Find all document highlights for a symbol at the given position.
/// Document highlights are limited to the current file only.
+///
+/// On a keyword, the highlights are the keywords that belong with it — see `keyword_family`.
pub fn document_highlights(
db: &dyn Db,
file: ProgramFile<'_>,
offset: TextSize,
) -> Option> {
+ // A keyword is not a symbol, but it has occurrences that belong together — the `elif`s and
+ // `else` of an `if`, the `return`s of a `def` — and those are what an editor lights up for it.
+ if let Some(family) = keyword_family(db, file.python_file(db), offset) {
+ let target_file = file.file(db);
+ return Some(
+ family
+ .into_iter()
+ .map(|range| ReferenceTarget::new(target_file, range, ReferenceKind::Other))
+ .collect(),
+ );
+ }
+
let parsed = ruff_db::parsed::parsed_module(db, file.python_file(db));
let module = parsed.load(db);
let model = SemanticModel::new(db, file);
diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs
index ad42d65f7b..8693f69e83 100644
--- a/crates/ty_ide/src/inlay_hints.rs
+++ b/crates/ty_ide/src/inlay_hints.rs
@@ -32,7 +32,7 @@ use ty_python_semantic::types::ide_support::{
is_composable_function, is_reveal_type_function, is_union_special_form, numeric_promotion,
parameter_stability, trailing_lambda_implicit_parameters, type_parameter_names,
};
-use ty_python_semantic::types::{DisplaySettings, Type, TypeDetail};
+use ty_python_semantic::types::{DisplaySettings, Type, TypeDetail, TypeDisplayDetails};
use ty_python_semantic::{HasType, SemanticModel, with_display_for_file};
#[derive(Debug, Clone)]
@@ -58,151 +58,33 @@ impl InlayHint {
expr: &Expr,
rhs: &Expr,
ty: Type<'db>,
- mut allow_edits: bool,
+ allow_edits: bool,
named_type_arguments: bool,
) -> Option {
- let InlayHintImportContext {
- db,
- file,
- importer,
- dynamic_imports,
- } = context;
-
let position = expr.range().end();
- let env = ProgramEnvironment::from_file(file);
- // Render the type to a string, and get subspans for all the types that make it up
- let settings = DisplaySettings::from_possibly_ambiguous_types(db, &env, [ty]);
- let settings = if named_type_arguments {
- settings.with_named_type_arguments()
- } else {
- settings
- };
- let details = ty.display_with(db, &env, settings).to_string_parts();
+ let details = type_display_details(context.db, context.file, ty, named_type_arguments);
// Filter out repetitive hints like `x: T = T()`
if call_matches_name(rhs, &details.label) {
return None;
}
- let mut dynamic_importer = DynamicImporter::new(importer, expr, dynamic_imports);
-
- // Ok so the idea here is that we potentially have a random soup of spans here,
- // and each byte of the string can have at most one target associate with it.
- // Thankfully, they were generally pushed in print order, with the inner smaller types
- // appearing before the outer bigger ones.
- //
- // So we record where we are in the string, and every time we find a type, we
- // check if it's further along in the string. If it is, great, we give it the
- // span for its range, and then advance where we are.
- let mut offset = 0;
-
- // This edit label could be different from the original label if we need to
- // qualify certain imported symbols. `A` could turn into `foo.A`.
- let mut edit_label = details.label.clone();
- let mut edit_offset: isize = 0;
-
- let mut label_parts = vec![": ".into()];
- for (target, detail) in details.targets.iter().zip(&details.details) {
- match detail {
- TypeDetail::Type(ty) => {
- let start = target.start().to_usize();
- let end = target.end().to_usize();
- // If we skipped over some bytes, push them with no target
- if start > offset {
- label_parts.push(details.label[offset..start].into());
- }
-
- // Possibly import the current type and return the qualified name
- let mut qualified_name = |dynamic_importer: &mut DynamicImporter<'_, 'db>| {
- let type_definition = ty.definition(db, &env)?;
- let definition = type_definition.definition()?;
-
- // Only module-level names can be imported with `from import `.
- // If the definition lives in a class or function body we can't produce a safe edit.
- if !definition.file_scope(db).is_global() {
- allow_edits = false;
- return None;
- }
-
- // Don't try to import symbols in scope
- let definition_file = definition.file(db);
- if definition_file == file.file(db) {
- return None;
- }
-
- let definition_name = definition.name(db);
-
- // Fallback to the label if we cannot find the name
- let definition_name = definition_name
- .as_deref()
- .unwrap_or(&details.label[start..end]);
-
- let file = definition.program_file(db);
- let module = file_to_module(db, file.resolver_file(db))?;
-
- if should_skip_import(db, module, *ty) {
- return None;
- }
-
- let module_name = module.name(db).as_str();
-
- dynamic_importer.import_symbol(
- db,
- &env,
- ty,
- module_name,
- definition_name,
- &details.label[start..end],
- )
- };
-
- // Ok, this is the first type that claimed these bytes, give it the target
- if start >= offset {
- // Try to import the symbol and update the edit label if required
- if let Some(qualified_name) = qualified_name(&mut dynamic_importer) {
- let edit_start = (start.cast_signed() + edit_offset).cast_unsigned();
- let edit_end = (end.cast_signed() + edit_offset).cast_unsigned();
-
- edit_label.replace_range(edit_start..edit_end, &qualified_name);
- edit_offset +=
- qualified_name.len().cast_signed() - (end - start).cast_signed();
- }
-
- let target = ty.navigation_targets(db, &env).into_iter().next();
-
- // Always use original text for the label part
- label_parts.push(
- InlayHintLabelPart::new(&details.label[start..end]).with_target(target),
- );
- offset = end;
- }
- }
- TypeDetail::SignatureStart
- | TypeDetail::SignatureEnd
- | TypeDetail::Parameter(_) => {
- // Don't care about these
- }
- }
- }
-
- // "flush" the rest of the label without any target
- if offset < details.label.len() {
- label_parts.push(details.label[offset..details.label.len()].into());
- }
+ let spelled = spell_type(context, expr.into(), expr.range().start(), &details);
- let text_edits = if details.is_valid_syntax && allow_edits {
+ let text_edits = if spelled.editable && allow_edits {
let mut text_edits = vec![InlayHintTextEdit {
range: TextRange::new(position, position),
- new_text: format!(": {edit_label}"),
+ new_text: format!(": {}", spelled.edit_label),
}];
-
- text_edits.extend(dynamic_importer.text_edits());
-
+ text_edits.extend(spelled.import_edits);
text_edits
} else {
vec![]
};
+ let mut label_parts = vec![": ".into()];
+ label_parts.extend(spelled.label_parts);
+
Some(Self {
position,
kind: InlayHintKind::Type,
@@ -1175,6 +1057,209 @@ impl Default for InlayHintSettings {
}
}
+/// `ty` rendered to a string, with the spans of the types that make it up.
+fn type_display_details<'db>(
+ db: &'db dyn Db,
+ file: ProgramFile<'db>,
+ ty: Type<'db>,
+ named_type_arguments: bool,
+) -> TypeDisplayDetails<'db> {
+ let env = ProgramEnvironment::from_file(file);
+ let settings = DisplaySettings::from_possibly_ambiguous_types(db, &env, [ty]);
+ let settings = if named_type_arguments {
+ settings.with_named_type_arguments()
+ } else {
+ settings
+ };
+ // basedpython: a symbolic operation such as a repeated tuple, `(1, "a") * int`, has no
+ // python spelling, so a hint in a python file shows the type it reduces to — which that
+ // file can also hold as an annotation
+ let settings = if file.file(db).source_type(db).is_basedpython() {
+ settings
+ } else {
+ settings.with_reduced_symbolic_operations()
+ };
+ ty.display_with(db, &env, settings).to_string_parts()
+}
+
+/// A type as a hint shows it and as an annotation would write it.
+struct SpelledType {
+ /// The rendering, each type in it linked to its definition.
+ label_parts: Vec,
+ /// The annotation to write, which qualifies a name where importing it
+ /// would clash with one in scope.
+ edit_label: String,
+ /// The imports the annotation needs.
+ import_edits: Vec,
+ /// Whether the annotation can be written at all: it is valid syntax, and
+ /// every type it names can be reached from the file.
+ editable: bool,
+}
+
+/// Spell `details` for the scope around `scope_node`, importing what it names.
+fn spell_type<'db>(
+ context: InlayHintImportContext<'_, 'db>,
+ scope_node: AnyNodeRef<'_>,
+ scope_offset: TextSize,
+ details: &TypeDisplayDetails<'db>,
+) -> SpelledType {
+ let InlayHintImportContext {
+ db,
+ file,
+ importer,
+ dynamic_imports,
+ } = context;
+ let env = ProgramEnvironment::from_file(file);
+ let mut editable = details.is_valid_syntax;
+
+ let mut dynamic_importer =
+ DynamicImporter::new(importer, scope_node, scope_offset, dynamic_imports);
+
+ // Ok so the idea here is that we potentially have a random soup of spans here,
+ // and each byte of the string can have at most one target associate with it.
+ // Thankfully, they were generally pushed in print order, with the inner smaller types
+ // appearing before the outer bigger ones.
+ //
+ // So we record where we are in the string, and every time we find a type, we
+ // check if it's further along in the string. If it is, great, we give it the
+ // span for its range, and then advance where we are.
+ let mut offset = 0;
+
+ // This edit label could be different from the original label if we need to
+ // qualify certain imported symbols. `A` could turn into `foo.A`.
+ let mut edit_label = details.label.clone();
+ let mut edit_offset: isize = 0;
+
+ let mut label_parts = Vec::new();
+ for (target, detail) in details.targets.iter().zip(&details.details) {
+ match detail {
+ TypeDetail::Type(ty) => {
+ let start = target.start().to_usize();
+ let end = target.end().to_usize();
+ // If we skipped over some bytes, push them with no target
+ if start > offset {
+ label_parts.push(details.label[offset..start].into());
+ }
+
+ // Possibly import the current type and return the qualified name
+ let mut qualified_name = |dynamic_importer: &mut DynamicImporter<'_, 'db>| {
+ let type_definition = ty.definition(db, &env)?;
+ let definition = type_definition.definition()?;
+
+ // Only module-level names can be imported with `from import `.
+ // If the definition lives in a class or function body we can't produce a safe edit.
+ if !definition.file_scope(db).is_global() {
+ editable = false;
+ return None;
+ }
+
+ // Don't try to import symbols in scope
+ let definition_file = definition.file(db);
+ if definition_file == file.file(db) {
+ return None;
+ }
+
+ let definition_name = definition.name(db);
+
+ // Fallback to the label if we cannot find the name
+ let definition_name = definition_name
+ .as_deref()
+ .unwrap_or(&details.label[start..end]);
+
+ let file = definition.program_file(db);
+ let module = file_to_module(db, file.resolver_file(db))?;
+
+ if should_skip_import(db, module, *ty) {
+ return None;
+ }
+
+ let module_name = module.name(db).as_str();
+
+ dynamic_importer.import_symbol(
+ db,
+ &env,
+ ty,
+ module_name,
+ definition_name,
+ &details.label[start..end],
+ )
+ };
+
+ // Ok, this is the first type that claimed these bytes, give it the target
+ if start >= offset {
+ // Try to import the symbol and update the edit label if required
+ if let Some(qualified_name) = qualified_name(&mut dynamic_importer) {
+ let edit_start = (start.cast_signed() + edit_offset).cast_unsigned();
+ let edit_end = (end.cast_signed() + edit_offset).cast_unsigned();
+
+ edit_label.replace_range(edit_start..edit_end, &qualified_name);
+ edit_offset +=
+ qualified_name.len().cast_signed() - (end - start).cast_signed();
+ }
+
+ let target = ty.navigation_targets(db, &env).into_iter().next();
+
+ // Always use original text for the label part
+ label_parts.push(
+ InlayHintLabelPart::new(&details.label[start..end]).with_target(target),
+ );
+ offset = end;
+ }
+ }
+ TypeDetail::SignatureStart | TypeDetail::SignatureEnd | TypeDetail::Parameter(_) => {
+ // Don't care about these
+ }
+ }
+ }
+
+ // "flush" the rest of the label without any target
+ if offset < details.label.len() {
+ label_parts.push(details.label[offset..details.label.len()].into());
+ }
+
+ SpelledType {
+ label_parts,
+ edit_label,
+ import_edits: dynamic_importer.text_edits(),
+ editable,
+ }
+}
+
+/// `ty` written as an annotation in `file`, for a position in the scope around
+/// `scope_node`, with the imports it needs; `None` when no annotation the file
+/// could hold says it.
+pub(crate) fn annotation_for_type<'db>(
+ db: &'db dyn Db,
+ file: ProgramFile<'db>,
+ scope_node: AnyNodeRef<'_>,
+ scope_offset: TextSize,
+ ty: Type<'db>,
+) -> Option<(String, Vec)> {
+ let ast = parsed_module(db, file.python_file(db)).load(db);
+ let source = source_text(db, file.file(db));
+ let stylist = Stylist::from_tokens(ast.tokens(), source.as_str());
+ let importer = Importer::new(db, &stylist, file, source.as_str(), &ast);
+ let mut dynamic_imports = FxHashMap::default();
+ // a basedpython file writes types in basedpython's own spelling
+ with_display_for_file(db, file.file(db), || {
+ let details = type_display_details(db, file, ty, false);
+ let spelled = spell_type(
+ InlayHintImportContext {
+ db,
+ file,
+ importer: &importer,
+ dynamic_imports: &mut dynamic_imports,
+ },
+ scope_node,
+ scope_offset,
+ &details,
+ );
+ spelled
+ .editable
+ .then_some((spelled.edit_label, spelled.import_edits))
+ })
+}
+
struct InlayHintImportContext<'a, 'db> {
db: &'db dyn Db,
file: ProgramFile<'db>,
@@ -2518,13 +2603,14 @@ struct DynamicImporter<'a, 'db> {
impl<'a, 'db> DynamicImporter<'a, 'db> {
fn new(
importer: &'a Importer<'db>,
- expr: &'a Expr,
+ scope_node: AnyNodeRef<'a>,
+ scope_offset: TextSize,
dynamic_imports: &'a mut FxHashMap,
) -> Self {
Self {
importer,
- scope_node: expr.into(),
- scope_offset: expr.range().start(),
+ scope_node,
+ scope_offset,
members: None,
dynamic_imports,
imported_members: Vec::new(),
@@ -3594,6 +3680,150 @@ Source with applied edits:
");
}
+ /// python has no spelling for a repeated tuple, so a hint in a python file shows the type it
+ /// reduces to, which that file can hold as an annotation
+ #[test]
+ fn test_repeated_tuple_hint_is_reduced_in_python() {
+ let mut test = inlay_hint_test(
+ "
+ def f(n: int):
+ pairs = (1, 'a') * n
+ ",
+ );
+
+ assert_snapshot!(test.inlay_hints(), @r#"
+
+ def f(n: int):
+ pairs[: tuple[Literal[1, "a"], ...]] = (1, 'a') * n
+
+ ---------------------------------------------
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class tuple[out Element](Sequence[Element]):
+ | ^^^^^
+ info: Source
+ --> main2.py:LL:13
+ |
+ LL | pairs[: tuple[Literal[1, "a"], ...]] = (1, 'a') * n
+ | ^^^^^
+
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/typing.byi:LL:1
+ |
+ LL | Literal: _SpecialForm
+ | ^^^^^^^
+ info: Source
+ --> main2.py:LL:19
+ |
+ LL | pairs[: tuple[Literal[1, "a"], ...]] = (1, 'a') * n
+ | ^^^^^^^
+
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class int:
+ | ^^^
+ info: Source
+ --> main2.py:LL:27
+ |
+ LL | pairs[: tuple[Literal[1, "a"], ...]] = (1, 'a') * n
+ | ^
+
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class str(Sequence[str]):
+ | ^^^
+ info: Source
+ --> main2.py:LL:30
+ |
+ LL | pairs[: tuple[Literal[1, "a"], ...]] = (1, 'a') * n
+ | ^^^
+
+ ---------------------------------------------
+ info[inlay-hint-edit]: Inlay hint edits
+ --> main.py:1:1
+ |
+ 1 + from typing import Literal
+ 2 |
+ 3 | def f(n: int):
+ - pairs = (1, 'a') * n
+ 4 + pairs: tuple[Literal[1, "a"], ...] = (1, 'a') * n
+ |
+ "#);
+ }
+
+ #[test]
+ fn test_repeated_tuple_hint_is_insertable_in_basedpython() {
+ let mut test = basedpython_inlay_hint_test(
+ "
+ def f(n: int):
+ pairs = (1, 'a') * n
+ ",
+ );
+
+ assert_snapshot!(test.inlay_hints(), @r#"
+
+ def f(n: int):
+ pairs[: (1, "a") * int] = (1, 'a') * n
+
+ ---------------------------------------------
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class tuple[out Element](Sequence[Element]):
+ | ^^^^^
+ info: Source
+ --> main2.py:LL:13
+ |
+ LL | pairs[: (1, "a") * int] = (1, 'a') * n
+ | ^
+
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class int:
+ | ^^^
+ info: Source
+ --> main2.py:LL:14
+ |
+ LL | pairs[: (1, "a") * int] = (1, 'a') * n
+ | ^
+
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class str(Sequence[str]):
+ | ^^^
+ info: Source
+ --> main2.py:LL:17
+ |
+ LL | pairs[: (1, "a") * int] = (1, 'a') * n
+ | ^^^
+
+ info[inlay-hint-location]: Inlay Hint Target
+ --> stdlib/builtins.byi:LL:7
+ |
+ LL | class int:
+ | ^^^
+ info: Source
+ --> main2.py:LL:24
+ |
+ LL | pairs[: (1, "a") * int] = (1, 'a') * n
+ | ^^^
+
+ ---------------------------------------------
+ info[inlay-hint-edit]: Inlay hint edits
+ --> main.by:1:1
+ |
+ 2 | def f(n: int):
+ - pairs = (1, 'a') * n
+ 3 + pairs: (1, "a") * int = (1, 'a') * n
+ |
+ "#);
+ }
+
#[test]
fn test_leading_underscore_variable_assignment_has_no_type_inlay_hint() {
let mut test = inlay_hint_test(
diff --git a/crates/ty_ide/src/lib.rs b/crates/ty_ide/src/lib.rs
index 624c6abd12..27b2adc4be 100644
--- a/crates/ty_ide/src/lib.rs
+++ b/crates/ty_ide/src/lib.rs
@@ -28,6 +28,7 @@ mod injection;
mod inlay_hints;
mod markup;
mod module_rename;
+mod refactor;
mod references;
mod rename;
mod selection_range;
@@ -35,6 +36,7 @@ mod semantic_tokens;
mod signature_help;
mod stub_mapping;
mod symbols;
+mod syntax_outline;
mod type_hierarchy;
mod workspace_symbols;
@@ -76,6 +78,7 @@ pub use markup::MarkupKind;
pub use module_rename::{
FileMove, ModuleRenameEdits, SkipReason, SkippedImport, module_rename_edits,
};
+pub use refactor::{Refactor, RefactorKind, RefactorOffer, refactor, refactors};
pub use references::ReferencesMode;
pub use rename::{can_rename, rename};
pub use selection_range::selection_range;
@@ -85,6 +88,10 @@ pub use semantic_tokens::{
};
pub use signature_help::{ParameterDetails, SignatureDetails, SignatureHelpInfo, signature_help};
pub use symbols::{FlatSymbols, HierarchicalSymbols, SymbolId, SymbolInfo, SymbolKind};
+pub use syntax_outline::{
+ OutlineCall, OutlineClause, OutlineModifier, OutlineStatement, OutlineString, SyntaxOutline,
+ syntax_outline,
+};
pub use type_hierarchy::{
TypeHierarchyItem, prepare_type_hierarchy, type_hierarchy_subtypes, type_hierarchy_supertypes,
};
diff --git a/crates/ty_ide/src/refactor.rs b/crates/ty_ide/src/refactor.rs
new file mode 100644
index 0000000000..03090e621c
--- /dev/null
+++ b/crates/ty_ide/src/refactor.rs
@@ -0,0 +1,378 @@
+//! Refactorings offered as code actions: rewrites the user asks for at a
+//! position or selection, as opposed to the quick fixes a diagnostic carries.
+//!
+//! Every refactoring answers from the syntax tree and the semantic index rather
+//! than from the text, and refuses with a reason whenever it cannot show that
+//! the rewrite leaves the program meaning what it meant. A refusal is part of
+//! the answer: a client that asked for a refactoring explicitly shows why it is
+//! unavailable.
+//!
+//! Each of them answers both questions at once — whether it applies here, and
+//! what it would rewrite — because the second *is* the first: a refactoring
+//! applies exactly when a rewrite that preserves meaning can be spelled, and
+//! there is no cheaper test that does not amount to a second implementation of
+//! the same reasoning, which could then disagree with it. So [`refactors`]
+//! hands its edits back rather than throwing them away, and the caller tells it
+//! which refactorings to consider rather than being given all of them.
+
+mod data_class;
+mod evaluation;
+mod extract_function;
+mod extract_variable;
+mod flow;
+mod hazards;
+mod inline_variable;
+mod names;
+mod return_annotation;
+mod text;
+
+use ruff_db::files::File;
+use ruff_db::parsed::{ParsedModuleRef, parsed_module};
+use ruff_db::source::{SourceText, source_text};
+use ruff_diagnostics::Edit;
+use ruff_python_codegen::Stylist;
+use ruff_text_size::TextRange;
+use ty_project::Db;
+use ty_python_core::ProgramFile;
+use ty_python_semantic::SemanticModel;
+
+use crate::FileEdit;
+
+/// The refactorings the server knows how to perform.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum RefactorKind {
+ InlineVariable,
+ ExtractVariable,
+ IntroduceConstant,
+ ExtractFunction,
+ AddReturnAnnotation,
+ ConvertToDataClass,
+ ConvertFromDataClass,
+}
+
+impl RefactorKind {
+ pub const ALL: &'static [RefactorKind] = &[
+ RefactorKind::InlineVariable,
+ RefactorKind::ExtractVariable,
+ RefactorKind::IntroduceConstant,
+ RefactorKind::ExtractFunction,
+ RefactorKind::AddReturnAnnotation,
+ RefactorKind::ConvertToDataClass,
+ RefactorKind::ConvertFromDataClass,
+ ];
+
+ /// The LSP code action kind the refactoring is offered as.
+ pub const fn code_action_kind(self) -> &'static str {
+ match self {
+ RefactorKind::InlineVariable => "refactor.inline.variable",
+ RefactorKind::ExtractVariable => "refactor.extract.variable",
+ RefactorKind::IntroduceConstant => "refactor.extract.constant",
+ RefactorKind::ExtractFunction => "refactor.extract.function",
+ RefactorKind::AddReturnAnnotation => "refactor.rewrite.returnAnnotation",
+ RefactorKind::ConvertToDataClass => "refactor.rewrite.toDataClass",
+ RefactorKind::ConvertFromDataClass => "refactor.rewrite.fromDataClass",
+ }
+ }
+
+ /// A stable name for the refactoring, which round-trips through a code
+ /// action's `data` so the edits can be computed when the action is resolved.
+ pub const fn id(self) -> &'static str {
+ match self {
+ RefactorKind::InlineVariable => "inline-variable",
+ RefactorKind::ExtractVariable => "extract-variable",
+ RefactorKind::IntroduceConstant => "introduce-constant",
+ RefactorKind::ExtractFunction => "extract-function",
+ RefactorKind::AddReturnAnnotation => "add-return-annotation",
+ RefactorKind::ConvertToDataClass => "convert-to-data-class",
+ RefactorKind::ConvertFromDataClass => "convert-from-data-class",
+ }
+ }
+
+ pub fn from_id(id: &str) -> Option {
+ Self::ALL.iter().copied().find(|kind| kind.id() == id)
+ }
+}
+
+/// A refactoring available at a range, or the reason it is not.
+#[derive(Debug, Clone)]
+pub struct RefactorOffer {
+ pub kind: RefactorKind,
+ pub title: String,
+ /// `Ok` holds the rewrite and `Err` why there is none.
+ ///
+ /// The edits come with the offer because working them out is how the question was answered:
+ /// a refactoring applies exactly when its rewrite can be spelled, so there is no cheaper
+ /// test to run first. A caller that only needs to know *whether* to offer it may drop them.
+ pub availability: Result, String>,
+}
+
+/// A refactoring worked out in full.
+pub struct Refactor {
+ pub kind: RefactorKind,
+ pub title: String,
+ pub edits: Vec,
+}
+
+/// Why a refactoring does not apply: either it is not about what is at the
+/// range at all, or it is but would change what the program means.
+pub(crate) enum Refusal {
+ /// Nothing at the range is the kind of thing the refactoring rewrites.
+ NotApplicable,
+ /// The refactoring is about what is at the range, but cannot be applied.
+ Refused { title: String, reason: String },
+}
+
+impl Refusal {
+ fn refused(title: impl Into, reason: impl Into) -> Self {
+ Refusal::Refused {
+ title: title.into(),
+ reason: reason.into(),
+ }
+ }
+}
+
+/// A planned rewrite of one file.
+pub(crate) struct Plan {
+ title: String,
+ edits: Vec,
+}
+
+/// Everything a refactoring reads about the file it rewrites.
+pub(crate) struct RefactorContext<'db> {
+ db: &'db dyn Db,
+ file: ProgramFile<'db>,
+ model: SemanticModel<'db>,
+ parsed: ParsedModuleRef,
+ source: SourceText,
+ stylist: Stylist<'static>,
+}
+
+impl<'db> RefactorContext<'db> {
+ fn new(db: &'db dyn Db, file: ProgramFile<'db>) -> Self {
+ let parsed = parsed_module(db, file.python_file(db)).load(db);
+ let source = source_text(db, file.file(db));
+ let stylist = Stylist::from_tokens(parsed.tokens(), source.as_str()).into_owned();
+ Self {
+ db,
+ file,
+ model: SemanticModel::new(db, file),
+ parsed,
+ source,
+ stylist,
+ }
+ }
+
+ fn source(&self) -> &str {
+ self.source.as_str()
+ }
+
+ fn file(&self) -> File {
+ self.file.file(self.db)
+ }
+
+ fn is_basedpython(&self) -> bool {
+ self.file().source_type(self.db).is_basedpython()
+ }
+
+ /// A plan's edits, each carrying the file it applies to.
+ fn file_edits(&self, edits: Vec) -> Vec {
+ let file = self.file();
+ edits
+ .into_iter()
+ .map(|edit| FileEdit { file, edit })
+ .collect()
+ }
+
+ fn plan(&self, kind: RefactorKind, range: TextRange) -> Result {
+ match kind {
+ RefactorKind::InlineVariable => inline_variable::plan(self, range),
+ RefactorKind::ExtractVariable => {
+ extract_variable::plan(self, range, extract_variable::Target::Variable)
+ }
+ RefactorKind::IntroduceConstant => {
+ extract_variable::plan(self, range, extract_variable::Target::Constant)
+ }
+ RefactorKind::ExtractFunction => extract_function::plan(self, range),
+ RefactorKind::AddReturnAnnotation => return_annotation::plan(self, range),
+ RefactorKind::ConvertToDataClass => {
+ data_class::plan(self, range, data_class::Direction::ToDataClass)
+ }
+ RefactorKind::ConvertFromDataClass => {
+ data_class::plan(self, range, data_class::Direction::FromDataClass)
+ }
+ }
+ }
+}
+
+/// Those of `kinds` that are about what is at `range` in `file`, each with its rewrite or the
+/// reason there is none.
+///
+/// `kinds` is asked for rather than assumed because answering for one costs working its rewrite
+/// out, so a caller that will not offer a refactoring should not name it — a client narrowing a
+/// `textDocument/codeAction` with `only` then pays for nothing it asked no question about.
+pub fn refactors(
+ db: &dyn Db,
+ file: ProgramFile<'_>,
+ range: TextRange,
+ kinds: &[RefactorKind],
+) -> Vec {
+ if kinds.is_empty() || !is_refactorable(db, file) {
+ return Vec::new();
+ }
+ let context = RefactorContext::new(db, file);
+ if context.parsed.has_invalid_syntax() {
+ return Vec::new();
+ }
+
+ kinds
+ .iter()
+ .copied()
+ .filter_map(|kind| match context.plan(kind, range) {
+ Ok(plan) => Some(RefactorOffer {
+ kind,
+ title: plan.title,
+ availability: Ok(context.file_edits(plan.edits)),
+ }),
+ Err(Refusal::NotApplicable) => None,
+ Err(Refusal::Refused { title, reason }) => Some(RefactorOffer {
+ kind,
+ title,
+ availability: Err(reason),
+ }),
+ })
+ .collect()
+}
+
+/// The edits of the refactoring `kind` at `range`, or why it cannot be applied.
+pub fn refactor(
+ db: &dyn Db,
+ file: ProgramFile<'_>,
+ kind: RefactorKind,
+ range: TextRange,
+) -> Result {
+ const NOT_HERE: &str = "the refactoring does not apply here";
+ if !is_refactorable(db, file) {
+ return Err(NOT_HERE.to_string());
+ }
+ let context = RefactorContext::new(db, file);
+ if context.parsed.has_invalid_syntax() {
+ return Err("the file has syntax errors".to_string());
+ }
+ match context.plan(kind, range) {
+ Ok(plan) => Ok(Refactor {
+ kind,
+ title: plan.title,
+ edits: context.file_edits(plan.edits),
+ }),
+ Err(Refusal::NotApplicable) => Err(NOT_HERE.to_string()),
+ Err(Refusal::Refused { reason, .. }) => Err(reason),
+ }
+}
+
+/// Only python and basedpython source is rewritten: a notebook's cells and a
+/// stub's declarations are not code these refactorings reason about.
+fn is_refactorable(db: &dyn Db, file: ProgramFile<'_>) -> bool {
+ file.file(db).source_type(db).is_py_file()
+}
+
+#[cfg(test)]
+pub(crate) mod test_support {
+ use ruff_db::files::{File, system_path_to_file};
+ use ruff_db::system::{DbWithWritableSystem, SystemPathBuf};
+ use ruff_python_trivia::textwrap::dedent;
+ use ruff_text_size::{Ranged, TextRange, TextSize};
+ use ty_project::{ProjectMetadata, SemanticDb as _};
+
+ use super::{RefactorKind, refactor, refactors};
+
+ /// A file with a ``, or a ``…`` selection, to refactor.
+ pub(crate) struct RefactorTest {
+ db: ty_project::TestDb,
+ file: File,
+ range: TextRange,
+ }
+
+ impl RefactorTest {
+ pub(crate) fn python(source: &str) -> Self {
+ Self::with_files("main.py", source, &[])
+ }
+
+ pub(crate) fn basedpython(source: &str) -> Self {
+ Self::with_files("main.by", source, &[])
+ }
+
+ pub(crate) fn with_files(path: &str, source: &str, others: &[(&str, &str)]) -> Self {
+ let mut db =
+ ty_project::TestDb::new(ProjectMetadata::new("test", SystemPathBuf::from("/")));
+ let mut text = dedent(source).into_owned();
+ let range = if let Some(cursor) = text.find("") {
+ text.replace_range(cursor..cursor + "".len(), "");
+ TextRange::empty(TextSize::try_from(cursor).unwrap())
+ } else {
+ let start = text
+ .find("")
+ .expect("a `` or `` marker");
+ text.replace_range(start..start + "".len(), "");
+ let end = text.find("").expect("an `` marker");
+ text.replace_range(end..end + "".len(), "");
+ TextRange::new(
+ TextSize::try_from(start).unwrap(),
+ TextSize::try_from(end).unwrap(),
+ )
+ };
+ for (other, contents) in others {
+ db.write_file(other, dedent(contents))
+ .expect("write to memory file system to be successful");
+ }
+ db.write_file(path, text)
+ .expect("write to memory file system to be successful");
+ let file = system_path_to_file(&db, path).expect("newly written file to exist");
+ Self { db, file, range }
+ }
+
+ /// The file after applying `kind`, or why it was not offered or refused.
+ pub(crate) fn apply(&self, kind: RefactorKind) -> String {
+ let program_file = self.db.program_file(self.file);
+ let offers = refactors(&self.db, program_file, self.range, RefactorKind::ALL);
+ let Some(offer) = offers.iter().find(|offer| offer.kind == kind) else {
+ return "not offered".to_string();
+ };
+ match refactor(&self.db, program_file, kind, self.range) {
+ Ok(result) => {
+ let offered = offer
+ .availability
+ .as_ref()
+ .expect("a refactoring that applies must be offered as available");
+ assert_eq!(offer.title, result.title);
+ // the offer and the resolve are two computations of one rewrite, and a
+ // client applies whichever it was handed
+ assert_eq!(
+ offered.iter().map(|edit| &edit.edit).collect::>(),
+ result
+ .edits
+ .iter()
+ .map(|edit| &edit.edit)
+ .collect::>(),
+ "the offered edits and the resolved edits must be the same rewrite"
+ );
+ let source = ruff_db::source::source_text(&self.db, self.file);
+ let mut text = source.as_str().to_string();
+ let mut edits: Vec<_> =
+ result.edits.into_iter().map(|edit| edit.edit).collect();
+ edits.sort_by_key(|edit| std::cmp::Reverse(edit.start()));
+ for edit in edits {
+ text.replace_range(
+ edit.start().to_usize()..edit.end().to_usize(),
+ edit.content().unwrap_or_default(),
+ );
+ }
+ format!("{}\n---\n{text}", offer.title)
+ }
+ Err(reason) => {
+ assert_eq!(offer.availability.as_ref().err(), Some(&reason));
+ format!("refused: {} ({reason})", offer.title)
+ }
+ }
+ }
+ }
+}
diff --git a/crates/ty_ide/src/refactor/data_class.rs b/crates/ty_ide/src/refactor/data_class.rs
new file mode 100644
index 0000000000..519dade0f8
--- /dev/null
+++ b/crates/ty_ide/src/refactor/data_class.rs
@@ -0,0 +1,561 @@
+//! basedpython's `data class` keyword, added to a class or taken off one.
+//!
+//! `data class Foo` lowers to `@dataclass(slots=True)`, so writing the keyword
+//! is a change of meaning the user asks for, not a rewrite that preserves it:
+//! the class gains an `__init__`, equality and a `__repr__`. What is refused is
+//! a class the keyword cannot apply to, and a class whose instances would stop
+//! working once they have slots — one that assigns an attribute the class never
+//! declares.
+
+use ruff_diagnostics::Edit;
+use ruff_python_ast::find_node::covering_node;
+use ruff_python_ast::token::TokenKind;
+use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal, walk_node};
+use ruff_python_ast::{self as ast, AnyNodeRef, Expr, ExprContext, Stmt};
+use ruff_text_size::{Ranged, TextRange};
+use rustc_hash::FxHashSet;
+use ty_python_semantic::HasType;
+use ty_python_semantic::types::ide_support::{
+ data_class_conversion_obstacle, is_dataclass_field_function,
+};
+
+use super::{Plan, RefactorContext, Refusal};
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum Direction {
+ ToDataClass,
+ FromDataClass,
+}
+
+/// The markers the parser records a `data class` or `frozen data class` with.
+const DATA_CLASS_MARKERS: [&str; 2] = ["data_class", "frozen_data_class"];
+
+pub(super) fn plan(
+ context: &RefactorContext<'_>,
+ range: TextRange,
+ direction: Direction,
+) -> Result {
+ if !context.is_basedpython() {
+ return Err(Refusal::NotApplicable);
+ }
+ let module = context.parsed.syntax();
+ if !module.range().contains_range(range) {
+ return Err(Refusal::NotApplicable);
+ }
+ let covering = covering_node(module.into(), range);
+ let Some(class) = covering.ancestors().find_map(|node| match node {
+ AnyNodeRef::StmtClassDef(class) if header(class).contains_range(range) => Some(class),
+ _ => None,
+ }) else {
+ return Err(Refusal::NotApplicable);
+ };
+
+ let marker = class.decorator_list.iter().find(|decorator| {
+ matches!(
+ &decorator.expression,
+ Expr::Name(name)
+ if name.ctx == ExprContext::Invalid && DATA_CLASS_MARKERS.contains(&name.id.as_str())
+ )
+ });
+
+ match (direction, marker) {
+ (Direction::FromDataClass, Some(marker)) => {
+ let title = "Convert to a plain class";
+ // taking the keyword off takes the generated `__init__` with it, and that is the
+ // change of meaning being asked for: a field then has no way to be filled, which
+ // the checker reports at every call that relied on it. a `field(...)` default is
+ // different — nothing reports it. the dataclass machinery reads that descriptor and
+ // replaces it; without it the `Field` object simply *is* the attribute's value, on
+ // every instance at once
+ if let Some(field) = dataclass_only_default(context, class) {
+ return Err(Refusal::refused(
+ title,
+ format!(
+ "`{field}` has a `field(...)` default, which only the dataclass \
+ machinery reads — a plain class would hold the descriptor itself"
+ ),
+ ));
+ }
+ Ok(Plan {
+ title: title.to_string(),
+ edits: vec![Edit::range_deletion(marker.range())],
+ })
+ }
+ (Direction::ToDataClass, None) => {
+ let title = "Convert to a data class";
+ if let Some(reason) = data_class_conversion_obstacle(&context.model, class) {
+ return Err(Refusal::refused(title, reason));
+ }
+ if let Some(reason) = slots_obstacle(class) {
+ return Err(Refusal::refused(title, reason));
+ }
+ let Some(keyword) = context
+ .parsed
+ .tokens()
+ .iter()
+ .filter(|token| {
+ token.kind() == TokenKind::Class
+ && TextRange::new(class.start(), class.name.start())
+ .contains_range(token.range())
+ })
+ .last()
+ else {
+ return Err(Refusal::NotApplicable);
+ };
+ Ok(Plan {
+ title: title.to_string(),
+ edits: vec![Edit::insertion("data ".to_string(), keyword.start())],
+ })
+ }
+ _ => Err(Refusal::NotApplicable),
+ }
+}
+
+/// The part of a class before its body.
+fn header(class: &ast::StmtClassDef) -> TextRange {
+ let end = class
+ .arguments
+ .as_deref()
+ .map(Ranged::end)
+ .or_else(|| class.type_params.as_deref().map(Ranged::end))
+ .unwrap_or(class.name.end());
+ TextRange::new(class.start(), end)
+}
+
+/// Why `class` would break once the dataclass machinery ran over it: the class writes its own
+/// `__slots__`, a method assigns an attribute of the instance the class body does not declare,
+/// or a method calls `super()` with no arguments.
+///
+/// The last of those is about slots rather than about `super`: building a slotted class means
+/// building a *new* class object, and the `__class__` cell the methods closed over still points
+/// at the original. `super()` reads that cell, so it would raise `TypeError` at runtime — which
+/// is why `dataclasses` documents zero-argument `super()` as not working under `slots=True`.
+fn slots_obstacle(class: &ast::StmtClassDef) -> Option {
+ let mut declared = FxHashSet::default();
+ for statement in &class.body {
+ let target = match statement {
+ Stmt::AnnAssign(assign) => &*assign.target,
+ Stmt::Assign(assign) => match assign.targets.as_slice() {
+ [target] => target,
+ _ => continue,
+ },
+ _ => continue,
+ };
+ if let Expr::Name(name) = target {
+ if name.id == "__slots__" {
+ return Some(
+ "the class writes its own `__slots__`, and a data class makes its own"
+ .to_string(),
+ );
+ }
+ if statement.is_ann_assign_stmt() {
+ declared.insert(name.id.to_string());
+ }
+ }
+ }
+
+ for statement in &class.body {
+ let Stmt::FunctionDef(method) = statement else {
+ continue;
+ };
+ if bare_super_call(&method.body) {
+ return Some(format!(
+ "`{}` calls `super()` with no arguments, and a slotted data class is a new class \
+ its methods do not close over — the call would raise at runtime",
+ method.name
+ ));
+ }
+ // a method with no receiver assigns no attribute of one. a static method's first
+ // parameter is not a receiver however it is named, so it is not read as one
+ if is_static_method(method) {
+ continue;
+ }
+ let Some(receiver) = method
+ .parameters
+ .posonlyargs
+ .iter()
+ .chain(&method.parameters.args)
+ .next()
+ else {
+ continue;
+ };
+ let mut finder = AttributeWrites {
+ receiver: receiver.parameter.name.id.as_str(),
+ declared: &declared,
+ undeclared: None,
+ };
+ for body_statement in &method.body {
+ walk_node(&mut finder, AnyNodeRef::from(body_statement));
+ }
+ if let Some(attribute) = finder.undeclared {
+ return Some(format!(
+ "`{}` assigns `{}.{attribute}`, which the class does not declare, and a slotted data class has no room for it",
+ method.name, receiver.parameter.name
+ ));
+ }
+ }
+ None
+}
+
+/// The first field of `class` whose default is a `dataclasses.field(...)` call, which means
+/// something only while the dataclass machinery is there to read it.
+///
+/// Which function is called is asked of the checker rather than read off the name, so a
+/// `field` the project defines itself is not mistaken for this one, and
+/// `dataclasses.field` is still recognised however it was imported or aliased.
+fn dataclass_only_default<'a>(
+ context: &RefactorContext<'_>,
+ class: &'a ast::StmtClassDef,
+) -> Option<&'a str> {
+ class.body.iter().find_map(|statement| {
+ let Stmt::AnnAssign(assign) = statement else {
+ return None;
+ };
+ let Expr::Name(name) = &*assign.target else {
+ return None;
+ };
+ let Expr::Call(call) = assign.value.as_deref()? else {
+ return None;
+ };
+ let callee = call.func.inferred_type(&context.model)?;
+ is_dataclass_field_function(context.db, callee).then(|| name.id.as_str())
+ })
+}
+
+/// Whether `method` is declared without a receiver, by decorator or by basedpython modifier.
+fn is_static_method(method: &ast::StmtFunctionDef) -> bool {
+ method.decorator_list.iter().any(|decorator| {
+ matches!(
+ &decorator.expression,
+ Expr::Name(name) if matches!(name.id.as_str(), "staticmethod" | "static")
+ )
+ })
+}
+
+/// Whether anything in `body` calls `super` with no arguments, outside a nested definition —
+/// which has a `__class__` cell of its own only when it is written in another class.
+fn bare_super_call(body: &[Stmt]) -> bool {
+ struct Finder {
+ found: bool,
+ }
+ impl<'a> SourceOrderVisitor<'a> for Finder {
+ fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
+ if self.found {
+ return TraversalSignal::Skip;
+ }
+ // a class written inside the method binds its own `__class__`, so a `super()` in it
+ // is not this class's
+ if node.is_stmt_class_def() {
+ return TraversalSignal::Skip;
+ }
+ if let AnyNodeRef::ExprCall(call) = node
+ && call.arguments.is_empty()
+ && matches!(&*call.func, Expr::Name(name) if name.id == "super")
+ {
+ self.found = true;
+ return TraversalSignal::Skip;
+ }
+ TraversalSignal::Traverse
+ }
+ }
+ let mut finder = Finder { found: false };
+ for statement in body {
+ walk_node(&mut finder, AnyNodeRef::from(statement));
+ }
+ finder.found
+}
+
+struct AttributeWrites<'a> {
+ receiver: &'a str,
+ declared: &'a FxHashSet,
+ undeclared: Option,
+}
+
+impl<'a> SourceOrderVisitor<'a> for AttributeWrites<'_> {
+ fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
+ if self.undeclared.is_some() {
+ return TraversalSignal::Skip;
+ }
+ // a nested function or class has a receiver of its own
+ if matches!(
+ node,
+ AnyNodeRef::StmtFunctionDef(_) | AnyNodeRef::StmtClassDef(_)
+ ) {
+ return TraversalSignal::Skip;
+ }
+ if let AnyNodeRef::ExprAttribute(attribute) = node
+ && attribute.ctx.is_store()
+ && matches!(&*attribute.value, Expr::Name(name) if name.id == self.receiver)
+ && !self.declared.contains(attribute.attr.as_str())
+ {
+ self.undeclared = Some(attribute.attr.to_string());
+ }
+ TraversalSignal::Traverse
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use insta::assert_snapshot;
+
+ use crate::refactor::RefactorKind;
+ use crate::refactor::test_support::RefactorTest;
+
+ fn to_data(source: &str) -> String {
+ RefactorTest::basedpython(source).apply(RefactorKind::ConvertToDataClass)
+ }
+
+ fn from_data(source: &str) -> String {
+ RefactorTest::basedpython(source).apply(RefactorKind::ConvertFromDataClass)
+ }
+
+ #[test]
+ fn adds_the_keyword() {
+ assert_snapshot!(to_data(
+ "
+ class Point:
+ x: int
+ y: int
+ ",
+ ), @"
+ Convert to a data class
+ ---
+
+ data class Point:
+ x: int
+ y: int
+ ");
+ }
+
+ #[test]
+ fn keyword_goes_after_other_modifiers() {
+ assert_snapshot!(to_data(
+ "
+ final class Point:
+ x: int
+ ",
+ ), @"
+ Convert to a data class
+ ---
+
+ final data class Point:
+ x: int
+ ");
+ }
+
+ #[test]
+ fn data_class_is_not_offered_to_become_one() {
+ assert_snapshot!(to_data(
+ "
+ data class Point:
+ x: int
+ ",
+ ), @"not offered");
+ }
+
+ #[test]
+ fn removes_the_keyword() {
+ assert_snapshot!(from_data(
+ "
+ data class Point:
+ x: int
+ ",
+ ), @"
+ Convert to a plain class
+ ---
+
+ class Point:
+ x: int
+ ");
+ }
+
+ /// `@dataclass(slots=True)` builds a new class object, and the methods closed over the old
+ /// one, so the `__class__` cell `super()` reads no longer matches the instance.
+ #[test]
+ fn a_class_calling_bare_super_is_refused() {
+ assert_snapshot!(to_data(
+ "
+ class Base:
+ def setup(self) -> None: ...
+
+ class Point(Base):
+ x: int
+
+ def setup(self) -> None:
+ super().setup()
+ ",
+ ), @"refused: Convert to a data class (`setup` calls `super()` with no arguments, and a slotted data class is a new class its methods do not close over — the call would raise at runtime)");
+ }
+
+ /// `super(Point, self)` reads no cell, so it survives the new class object.
+ #[test]
+ fn a_class_calling_super_with_arguments_is_converted() {
+ assert_snapshot!(to_data(
+ "
+ class Base:
+ def setup(self) -> None: ...
+
+ class Point(Base):
+ x: int
+
+ def setup(self) -> None:
+ super(Point, self).setup()
+ ",
+ ), @"
+ Convert to a data class
+ ---
+
+ class Base:
+ def setup(self) -> None: ...
+
+ data class Point(Base):
+ x: int
+
+ def setup(self) -> None:
+ super(Point, self).setup()
+ ");
+ }
+
+ /// A static method's first parameter is not a receiver, so what it assigns is not an
+ /// attribute of an instance of this class.
+ #[test]
+ fn a_static_method_is_not_read_as_having_a_receiver() {
+ assert_snapshot!(to_data(
+ "
+ class Point:
+ x: int
+
+ @staticmethod
+ def reset(other) -> None:
+ other.anything = 1
+ ",
+ ), @"
+ Convert to a data class
+ ---
+
+ data class Point:
+ x: int
+
+ @staticmethod
+ def reset(other) -> None:
+ other.anything = 1
+ ");
+ }
+
+ /// Nothing reports the loss of a `field(...)` default: the plain class would simply hold the
+ /// descriptor, shared by every instance.
+ #[test]
+ fn a_field_default_is_refused_going_back_to_a_plain_class() {
+ assert_snapshot!(from_data(
+ "
+ from dataclasses import field
+
+ data class Bag:
+ items: list[int] = field(default_factory=list)
+ ",
+ ), @"refused: Convert to a plain class (`items` has a `field(...)` default, which only the dataclass machinery reads — a plain class would hold the descriptor itself)");
+ }
+
+ /// A `field` the project defines itself is an ordinary call, and its value survives losing
+ /// the keyword. Which function is called is asked of the checker, so the name alone does not
+ /// decide it.
+ #[test]
+ fn a_projects_own_field_function_is_not_the_dataclass_one() {
+ assert_snapshot!(from_data(
+ "
+ def field(default: int) -> int:
+ return default
+
+ data class Bag:
+ size: int = field(3)
+ ",
+ ), @"
+ Convert to a plain class
+ ---
+
+ def field(default: int) -> int:
+ return default
+
+ class Bag:
+ size: int = field(3)
+ ");
+ }
+
+ #[test]
+ fn removes_frozen_with_it() {
+ assert_snapshot!(from_data(
+ "
+ frozen data class Point:
+ x: int
+ ",
+ ), @"
+ Convert to a plain class
+ ---
+
+ class Point:
+ x: int
+ ");
+ }
+
+ #[test]
+ fn plain_class_is_not_offered_to_become_plain() {
+ assert_snapshot!(from_data(
+ "
+ class Point:
+ x: int
+ ",
+ ), @"not offered");
+ }
+
+ #[test]
+ fn decorated_dataclass_is_refused() {
+ assert_snapshot!(to_data(
+ "
+ from dataclasses import dataclass
+
+ @dataclass
+ class Point:
+ x: int
+ ",
+ ), @"refused: Convert to a data class (the class is already a data class)");
+ }
+
+ #[test]
+ fn enum_is_refused() {
+ assert_snapshot!(to_data(
+ "
+ from enum import Enum
+
+ class Color(Enum):
+ RED = 1
+ ",
+ ), @"refused: Convert to a data class (an enum is not a data class)");
+ }
+
+ #[test]
+ fn undeclared_attribute_write_is_refused() {
+ assert_snapshot!(to_data(
+ "
+ class Point:
+ x: int
+
+ def move(self):
+ self.moved = True
+ ",
+ ), @"refused: Convert to a data class (`move` assigns `self.moved`, which the class does not declare, and a slotted data class has no room for it)");
+ }
+
+ #[test]
+ fn python_file_is_not_offered() {
+ assert_snapshot!(
+ RefactorTest::python(
+ "
+ class Point:
+ x: int
+ ",
+ )
+ .apply(RefactorKind::ConvertToDataClass),
+ @"not offered"
+ );
+ }
+}
diff --git a/crates/ty_ide/src/refactor/evaluation.rs b/crates/ty_ide/src/refactor/evaluation.rs
new file mode 100644
index 0000000000..ea2375826b
--- /dev/null
+++ b/crates/ty_ide/src/refactor/evaluation.rs
@@ -0,0 +1,545 @@
+//! When an expression is evaluated, relative to the statement it is part of.
+//!
+//! Moving an expression — hoisting it into a variable above its statement, or
+//! inlining a variable's value into the place that reads it — keeps the program
+//! meaning the same only when the expression is evaluated exactly once each time
+//! its statement runs, and when nothing evaluated before it could observe the
+//! move. This module answers both, following python's evaluation order.
+
+use ruff_python_ast::{self as ast, AnyNodeRef, Expr, Stmt, UnaryOp};
+use ruff_text_size::Ranged;
+
+/// Where a target expression sits in the evaluation of its statement.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Placement {
+ /// The target is evaluated exactly once whenever the statement runs.
+ /// `effects_before` says whether something evaluated before it may have an
+ /// effect: call a function, run a dunder, bind a name.
+ Once { effects_before: bool },
+ /// The target is not evaluated exactly once per run of the statement.
+ NotOnce(NotOnce),
+}
+
+/// Why an expression is not evaluated exactly once per run of its statement.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum NotOnce {
+ Conditional,
+ Loop,
+ Deferred,
+ Comprehension,
+ Body,
+ Annotation,
+ Assertion,
+ OptionalChain,
+ Opaque,
+}
+
+impl NotOnce {
+ pub(crate) fn describe(self) -> &'static str {
+ match self {
+ NotOnce::Conditional => "it is only evaluated when a condition holds",
+ NotOnce::Loop => "it is evaluated again on every iteration of the loop",
+ NotOnce::Deferred => "it is evaluated later, when the enclosing function is called",
+ NotOnce::Comprehension => "it is evaluated once per element of a comprehension",
+ NotOnce::Body => "it is inside the body of a compound statement",
+ NotOnce::Annotation => "it is part of an annotation",
+ NotOnce::Assertion => "an `assert` is not evaluated when python runs with `-O`",
+ NotOnce::OptionalChain => {
+ "it is a link of an optional chain, which is evaluated as a whole"
+ }
+ NotOnce::Opaque => "it is part of a construct whose evaluation order is not modelled",
+ }
+ }
+}
+
+/// Whether evaluating `expr` is free of effects and gives the same value no
+/// matter where it is evaluated, as long as the names it reads are not rebound.
+///
+/// This is deliberately narrow: a name, an immutable literal, a negated number
+/// and a tuple of those. An attribute can be a property, an operator can be a
+/// dunder, and a list display makes a new list each time it is evaluated.
+pub(crate) fn is_pure(expr: &Expr) -> bool {
+ match expr {
+ Expr::Name(name) => name.ctx.is_load(),
+ Expr::StringLiteral(_)
+ | Expr::BytesLiteral(_)
+ | Expr::NumberLiteral(_)
+ | Expr::BooleanLiteral(_)
+ | Expr::NoneLiteral(_)
+ | Expr::EllipsisLiteral(_) => true,
+ Expr::UnaryOp(unary) => {
+ matches!(unary.op, UnaryOp::USub | UnaryOp::UAdd | UnaryOp::Invert)
+ && unary.operand.is_number_literal_expr()
+ }
+ Expr::Tuple(tuple) => {
+ tuple.ctx.is_load()
+ && !tuple.is_anon_named_tuple
+ && !tuple.is_anon_named_tuple_value
+ && tuple.callable_shape.is_none()
+ && !tuple.is_parameter_shape
+ && tuple.elts.iter().all(is_pure)
+ }
+ _ => false,
+ }
+}
+
+/// Where the expression `target` sits in the evaluation of `statement`. `target`
+/// must be a node of `statement`'s tree, since it is found by identity.
+pub(crate) fn placement_in_statement(statement: &Stmt, target: AnyNodeRef<'_>) -> Placement {
+ let mut walk = Walk {
+ target,
+ effects: false,
+ };
+ walk.statement(statement)
+ .unwrap_or(Placement::NotOnce(NotOnce::Opaque))
+}
+
+struct Walk<'a> {
+ target: AnyNodeRef<'a>,
+ effects: bool,
+}
+
+type Found = Option;
+
+impl Walk<'_> {
+ fn contains(&self, node: impl Ranged) -> bool {
+ node.range().contains_range(self.target.range())
+ }
+
+ /// Something not modelled was evaluated: the target inside it is refused,
+ /// and a target after it has an effect before it.
+ fn opaque(&mut self, node: impl Ranged, reason: NotOnce) -> Found {
+ if self.contains(node) {
+ return Some(Placement::NotOnce(reason));
+ }
+ self.effects = true;
+ None
+ }
+
+ /// An expression evaluated at most once, or not at all, after what has
+ /// been walked so far.
+ fn not_once(&mut self, expr: &Expr, reason: NotOnce) -> Found {
+ if self.contains(expr) {
+ return Some(Placement::NotOnce(reason));
+ }
+ if !is_pure(expr) {
+ self.effects = true;
+ }
+ None
+ }
+
+ fn not_once_all<'e>(
+ &mut self,
+ exprs: impl IntoIterator- ,
+ reason: NotOnce,
+ ) -> Found {
+ for expr in exprs {
+ if let Some(found) = self.not_once(expr, reason) {
+ return Some(found);
+ }
+ }
+ None
+ }
+
+ fn exprs<'e>(&mut self, exprs: impl IntoIterator
- ) -> Found {
+ for expr in exprs {
+ if let Some(found) = self.expr(expr) {
+ return Some(found);
+ }
+ }
+ None
+ }
+
+ fn effect(&mut self) {
+ self.effects = true;
+ }
+
+ fn statement(&mut self, statement: &Stmt) -> Found {
+ match statement {
+ Stmt::Expr(stmt) => self.expr(&stmt.value),
+ Stmt::Return(stmt) => stmt.value.as_deref().and_then(|value| self.expr(value)),
+ Stmt::Assign(stmt) => {
+ if !stmt.decorator_list.is_empty() {
+ return self.opaque(stmt, NotOnce::Opaque);
+ }
+ if let Some(found) = self.expr(&stmt.value) {
+ return Some(found);
+ }
+ for target in &stmt.targets {
+ if let Some(found) = self.target(target) {
+ return Some(found);
+ }
+ }
+ None
+ }
+ Stmt::AugAssign(stmt) => {
+ // the target is read, the value evaluated, the operator run and
+ // the result stored. reading a name has no effect; reading an
+ // attribute or an item does
+ if !stmt.target.is_name_expr() {
+ if let Some(found) = self.target(&stmt.target) {
+ return Some(found);
+ }
+ self.effect();
+ }
+ let found = self.expr(&stmt.value);
+ self.effect();
+ found
+ }
+ Stmt::AnnAssign(stmt) => {
+ if !stmt.decorator_list.is_empty() || stmt.is_context {
+ return self.opaque(stmt, NotOnce::Opaque);
+ }
+ if self.contains(&*stmt.annotation) {
+ return Some(Placement::NotOnce(NotOnce::Annotation));
+ }
+ if let Some(value) = &stmt.value
+ && let Some(found) = self.expr(value)
+ {
+ return Some(found);
+ }
+ self.target(&stmt.target)
+ }
+ Stmt::If(stmt) => {
+ if stmt.pattern.is_some() {
+ return self.opaque(stmt, NotOnce::Opaque);
+ }
+ if let Some(found) = self.expr(&stmt.test) {
+ return Some(found);
+ }
+ for clause in &stmt.elif_else_clauses {
+ if let Some(test) = &clause.test
+ && self.contains(test)
+ {
+ return Some(Placement::NotOnce(NotOnce::Conditional));
+ }
+ }
+ self.contains(stmt)
+ .then_some(Placement::NotOnce(NotOnce::Body))
+ }
+ Stmt::While(stmt) => {
+ self.contains(stmt)
+ .then_some(Placement::NotOnce(if self.contains(&*stmt.test) {
+ NotOnce::Loop
+ } else {
+ NotOnce::Body
+ }))
+ }
+ Stmt::For(stmt) => {
+ if let Some(found) = self.expr(&stmt.iter) {
+ return Some(found);
+ }
+ self.contains(stmt)
+ .then_some(Placement::NotOnce(NotOnce::Body))
+ }
+ Stmt::With(stmt) => {
+ for item in &stmt.items {
+ if let Some(found) = self.expr(&item.context_expr) {
+ return Some(found);
+ }
+ // `__enter__` runs before the next item is evaluated
+ self.effect();
+ if let Some(vars) = &item.optional_vars
+ && self.contains(&**vars)
+ {
+ return Some(Placement::NotOnce(NotOnce::Opaque));
+ }
+ }
+ self.contains(stmt)
+ .then_some(Placement::NotOnce(NotOnce::Body))
+ }
+ Stmt::Match(stmt) => {
+ if let Some(found) = self.expr(&stmt.subject) {
+ return Some(found);
+ }
+ self.contains(stmt)
+ .then_some(Placement::NotOnce(NotOnce::Body))
+ }
+ Stmt::Raise(stmt) => {
+ if let Some(exc) = &stmt.exc
+ && let Some(found) = self.expr(exc)
+ {
+ return Some(found);
+ }
+ stmt.cause.as_deref().and_then(|cause| self.expr(cause))
+ }
+ Stmt::Assert(stmt) => self
+ .contains(stmt)
+ .then_some(Placement::NotOnce(NotOnce::Assertion)),
+ Stmt::Delete(stmt) => {
+ for target in &stmt.targets {
+ if let Some(found) = self.target(target) {
+ return Some(found);
+ }
+ }
+ None
+ }
+ Stmt::FunctionDef(_)
+ | Stmt::ClassDef(_)
+ | Stmt::TypeAlias(_)
+ | Stmt::Let(_)
+ | Stmt::Try(_)
+ | Stmt::Import(_)
+ | Stmt::ImportFrom(_)
+ | Stmt::Global(_)
+ | Stmt::Nonlocal(_)
+ | Stmt::Pass(_)
+ | Stmt::Break(_)
+ | Stmt::Continue(_)
+ | Stmt::IpyEscapeCommand(_) => self.opaque(statement, NotOnce::Opaque),
+ }
+ }
+
+ /// The parts of an assignment target that are evaluated before the store.
+ fn target(&mut self, target: &Expr) -> Found {
+ match target {
+ Expr::Name(_) => {
+ // the store binds the name before any later target is evaluated
+ self.effect();
+ None
+ }
+ Expr::Attribute(attribute) if !attribute.optional => {
+ let found = self.expr(&attribute.value);
+ self.effect();
+ found
+ }
+ Expr::Subscript(subscript) if !subscript.is_typeof && !subscript.is_type_decoration => {
+ if let Some(found) = self.expr(&subscript.value) {
+ return Some(found);
+ }
+ let found = self.expr(&subscript.slice);
+ self.effect();
+ found
+ }
+ Expr::Tuple(tuple) => {
+ self.effect();
+ for element in &tuple.elts {
+ if let Some(found) = self.target(element) {
+ return Some(found);
+ }
+ }
+ None
+ }
+ Expr::List(list) => {
+ self.effect();
+ for element in &list.elts {
+ if let Some(found) = self.target(element) {
+ return Some(found);
+ }
+ }
+ None
+ }
+ Expr::Starred(starred) => self.target(&starred.value),
+ _ => self.opaque(target, NotOnce::Opaque),
+ }
+ }
+
+ fn expr(&mut self, expr: &Expr) -> Found {
+ if AnyNodeRef::from(expr).ptr_eq(self.target) {
+ return Some(Placement::Once {
+ effects_before: self.effects,
+ });
+ }
+ if !self.contains(expr) {
+ if !is_pure(expr) {
+ self.effect();
+ }
+ return None;
+ }
+
+ match expr {
+ Expr::BoolOp(bool_op) => {
+ let (first, rest) = bool_op.values.split_first()?;
+ if let Some(found) = self.expr(first) {
+ return Some(found);
+ }
+ self.not_once_all(rest, NotOnce::Conditional)
+ }
+ Expr::Named(named) => {
+ let found = self.expr(&named.value);
+ self.effect();
+ found
+ }
+ Expr::BinOp(bin_op) => match bin_op.op {
+ ast::Operator::Coalesce => {
+ if let Some(found) = self.expr(&bin_op.left) {
+ return Some(found);
+ }
+ self.not_once(&bin_op.right, NotOnce::Conditional)
+ }
+ ast::Operator::Result => self.opaque(expr, NotOnce::Opaque),
+ _ => {
+ if let Some(found) = self.exprs([&*bin_op.left, &*bin_op.right]) {
+ return Some(found);
+ }
+ self.effect();
+ None
+ }
+ },
+ Expr::UnaryOp(unary) => match unary.op {
+ UnaryOp::Optional | UnaryOp::Propagate | UnaryOp::Force => {
+ self.opaque(expr, NotOnce::Opaque)
+ }
+ UnaryOp::Not | UnaryOp::Invert | UnaryOp::UAdd | UnaryOp::USub => {
+ let found = self.expr(&unary.operand);
+ self.effect();
+ found
+ }
+ },
+ Expr::Lambda(_) => Some(Placement::NotOnce(NotOnce::Deferred)),
+ Expr::If(if_expr) => {
+ if let Some(found) = self.expr(&if_expr.test) {
+ return Some(found);
+ }
+ self.not_once_all([&*if_expr.body, &*if_expr.orelse], NotOnce::Conditional)
+ }
+ Expr::Dict(dict) => {
+ for item in &dict.items {
+ if let Some(key) = &item.key
+ && let Some(found) = self.expr(key)
+ {
+ return Some(found);
+ }
+ if let Some(found) = self.expr(&item.value) {
+ return Some(found);
+ }
+ }
+ None
+ }
+ Expr::Set(set) => self.exprs(&set.elts),
+ Expr::List(list) => self.exprs(&list.elts),
+ Expr::Tuple(tuple) => {
+ if tuple.is_anon_named_tuple
+ || tuple.is_anon_named_tuple_value
+ || tuple.callable_shape.is_some()
+ || tuple.is_parameter_shape
+ {
+ return self.opaque(expr, NotOnce::Opaque);
+ }
+ self.exprs(&tuple.elts)
+ }
+ Expr::ListComp(comp) => self.comprehension(&comp.generators),
+ Expr::SetComp(comp) => self.comprehension(&comp.generators),
+ Expr::DictComp(comp) => self.comprehension(&comp.generators),
+ Expr::Generator(comp) => self.comprehension(&comp.generators),
+ Expr::Await(await_expr) => {
+ let found = self.expr(&await_expr.value);
+ self.effect();
+ found
+ }
+ Expr::Yield(yield_expr) => {
+ let found = yield_expr
+ .value
+ .as_deref()
+ .and_then(|value| self.expr(value));
+ self.effect();
+ found
+ }
+ Expr::YieldFrom(yield_from) => {
+ let found = self.expr(&yield_from.value);
+ self.effect();
+ found
+ }
+ Expr::Compare(compare) => {
+ if let Some(found) = self.expr(&compare.left) {
+ return Some(found);
+ }
+ let (first, rest) = compare.comparators.split_first()?;
+ if let Some(found) = self.expr(first) {
+ return Some(found);
+ }
+ self.effect();
+ // a chained comparison stops at the first comparison that fails
+ self.not_once_all(rest, NotOnce::Conditional)
+ }
+ Expr::Call(call) => {
+ if call.cast_kind.is_some() || call.is_string_tag {
+ return self.opaque(expr, NotOnce::Opaque);
+ }
+ if is_optional_chain(expr) {
+ return self.opaque(expr, NotOnce::OptionalChain);
+ }
+ if let Some(found) = self.expr(&call.func) {
+ return Some(found);
+ }
+ for argument in call.arguments.iter_source_order() {
+ if let Some(found) = self.expr(argument.value()) {
+ return Some(found);
+ }
+ }
+ self.effect();
+ None
+ }
+ Expr::Attribute(attribute) => {
+ if is_optional_chain(expr) {
+ return self.opaque(expr, NotOnce::OptionalChain);
+ }
+ let found = self.expr(&attribute.value);
+ self.effect();
+ found
+ }
+ Expr::Subscript(subscript) => {
+ if subscript.is_typeof || subscript.is_type_decoration {
+ return self.opaque(expr, NotOnce::Opaque);
+ }
+ if is_optional_chain(expr) {
+ return self.opaque(expr, NotOnce::OptionalChain);
+ }
+ if let Some(found) = self.exprs([&*subscript.value, &*subscript.slice]) {
+ return Some(found);
+ }
+ self.effect();
+ None
+ }
+ Expr::Starred(starred) => {
+ let found = self.expr(&starred.value);
+ self.effect();
+ found
+ }
+ Expr::Slice(slice) => self.exprs(
+ [&slice.lower, &slice.upper, &slice.step]
+ .into_iter()
+ .filter_map(|part| part.as_deref()),
+ ),
+ // a leaf that contains the target is the target, which was handled above
+ Expr::Name(_)
+ | Expr::StringLiteral(_)
+ | Expr::BytesLiteral(_)
+ | Expr::NumberLiteral(_)
+ | Expr::BooleanLiteral(_)
+ | Expr::NoneLiteral(_)
+ | Expr::EllipsisLiteral(_) => None,
+ // an f-string's interpolations are formatted as they are reached, but
+ // the target inside one is left where it is
+ Expr::FString(_)
+ | Expr::TString(_)
+ | Expr::IpyEscapeCommand(_)
+ | Expr::CallableType(_)
+ | Expr::ProtocolType(_)
+ | Expr::ProtocolMethod(_)
+ | Expr::Statement(_) => self.opaque(expr, NotOnce::Opaque),
+ }
+ }
+
+ /// Only the first iterable of a comprehension is evaluated in the enclosing
+ /// scope, once; everything else is evaluated per element.
+ fn comprehension(&mut self, generators: &[ast::Comprehension]) -> Found {
+ let (first, _) = generators.split_first()?;
+ if let Some(found) = self.expr(&first.iter) {
+ return Some(found);
+ }
+ Some(Placement::NotOnce(NotOnce::Comprehension))
+ }
+}
+
+/// Whether `expr` is part of a basedpython optional chain: a `?.` access, or a
+/// trailer applied to one. A chain is evaluated as a whole — an absent receiver
+/// skips every trailer after it — so no link of it is evaluated on its own.
+fn is_optional_chain(expr: &Expr) -> bool {
+ match expr {
+ Expr::Attribute(attribute) => attribute.optional || is_optional_chain(&attribute.value),
+ Expr::Call(call) => is_optional_chain(&call.func),
+ Expr::Subscript(subscript) => is_optional_chain(&subscript.value),
+ _ => false,
+ }
+}
diff --git a/crates/ty_ide/src/refactor/extract_function.rs b/crates/ty_ide/src/refactor/extract_function.rs
new file mode 100644
index 0000000000..466188594f
--- /dev/null
+++ b/crates/ty_ide/src/refactor/extract_function.rs
@@ -0,0 +1,1062 @@
+//! Extract Function / Extract Method: move selected statements into a new
+//! function and call it where they were.
+//!
+//! What the statements read and bind is taken from the semantic index. A
+//! variable of the enclosing function the statements read, and that was bound
+//! before them, becomes a parameter; a variable they bind that is read after
+//! them is returned and assigned at the call. Statements selected in a method
+//! become a method of the same class, called through the method's receiver.
+//!
+//! The rewrite is refused for statements whose meaning depends on being written
+//! inline: a `return`, `yield`, `break` or `continue` that leaves the selection,
+//! `global` and `nonlocal`, a variable that may be unbound when the new function
+//! would be called, or one assigned only on some paths and read afterwards.
+
+use std::fmt::Write as _;
+
+use ruff_diagnostics::Edit;
+use ruff_python_ast::name::Name;
+use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal, walk_node};
+use ruff_python_ast::{self as ast, AnyNodeRef, Expr, Stmt};
+use ruff_python_trivia::{PythonWhitespace, leading_indentation};
+use ruff_source_file::LineRanges;
+use ruff_text_size::{Ranged, TextRange, TextSize};
+use rustc_hash::FxHashSet;
+use ty_python_core::definition::{Definition, DefinitionKind};
+use ty_python_core::place::ScopedPlaceId;
+use ty_python_core::scope::{FileScopeId, NodeWithScopeRef};
+use ty_python_core::{SemanticIndex, semantic_index};
+use ty_python_semantic::HasType;
+use ty_python_semantic::types::list_members::all_members;
+
+use super::flow::{reaching_definitions, resolving_scope};
+use super::hazards::context_dependent_construct;
+use super::names::{fresh_name, names_in_file};
+use super::text::{leading_comments_start, nested_suites, statement_ancestors};
+use super::{Plan, RefactorContext, Refusal};
+
+pub(super) fn plan(context: &RefactorContext<'_>, range: TextRange) -> Result {
+ let Some(selection) = select_statements(context, range) else {
+ return Err(Refusal::NotApplicable);
+ };
+ let db = context.db;
+ let index = semantic_index(db, context.file);
+ let source = context.source();
+ let module = context.parsed.suite();
+ let first = selection.statements[0];
+ let last = *selection
+ .statements
+ .last()
+ .expect("a selection has statements");
+ let selected = TextRange::new(first.start(), last.end());
+
+ // the definition the statements are written in
+ let ancestors = statement_ancestors(module, first.range());
+ let owner = ancestors
+ .iter()
+ .rev()
+ .skip(1)
+ .find(|stmt| matches!(stmt, Stmt::FunctionDef(_) | Stmt::ClassDef(_)))
+ .copied();
+
+ let enclosing = match owner {
+ Some(Stmt::ClassDef(_)) => {
+ return Err(Refusal::refused(
+ "Extract function",
+ "statements in a class body define the class's attributes",
+ ));
+ }
+ Some(Stmt::FunctionDef(function)) => Some(function),
+ _ => None,
+ };
+ let scope = match enclosing {
+ Some(function) => {
+ if function.is_trailing_lambda {
+ return Err(Refusal::refused(
+ "Extract function",
+ "the statements are in a trailing lambda block",
+ ));
+ }
+ index
+ .try_node_scope(NodeWithScopeRef::Function(function))
+ .ok_or(Refusal::NotApplicable)?
+ }
+ None => FileScopeId::global(),
+ };
+
+ let method = match enclosing {
+ Some(function) if index.class_definition_of_method(scope).is_some() => {
+ Some(Method::of(context, function)?)
+ }
+ _ => None,
+ };
+ let class = method.as_ref().and_then(|_| {
+ ancestors
+ .iter()
+ .rev()
+ .skip(1)
+ .find_map(|stmt| stmt.as_class_def_stmt())
+ });
+
+ let mut taken = names_in_file(context);
+ if let Some(class) = class
+ && let Some(class_type) = class.inferred_type(&context.model)
+ {
+ let env = context.model.program_environment();
+ taken.extend(
+ all_members(db, &env, class_type)
+ .into_iter()
+ .map(|member| member.name.to_string()),
+ );
+ }
+ let name = fresh_name("extracted", &taken);
+ let title = if method.is_some() {
+ format!("Extract method `{name}`")
+ } else {
+ format!("Extract function `{name}`")
+ };
+ let refuse = |reason: String| Refusal::refused(title.clone(), reason);
+
+ // control flow that leaves the statements, and constructs that don't move
+ let mut scan = ControlFlowScan::default();
+ for statement in &selection.statements {
+ scan.visit_stmt(statement);
+ }
+ if let Some(reason) = scan.refusal {
+ return Err(refuse(reason));
+ }
+ if scan.awaits && !enclosing.is_some_and(|function| function.is_async) {
+ return Err(refuse(
+ "the statements await outside an `async def`".to_string(),
+ ));
+ }
+ for statement in &selection.statements {
+ if let Some(reason) = context_dependent_construct(context, AnyNodeRef::from(*statement)) {
+ return Err(refuse(format!("the statements can't be moved: {reason}")));
+ }
+ }
+
+ let in_selection = |range: TextRange| selected.contains_range(range);
+ let table = index.place_table(scope);
+
+ // what the statements bind in the enclosing scope
+ let use_def = index.use_def_map(scope);
+ let mut bound: Vec<(Name, Definition<'_>)> = Vec::new();
+ for (_, definition, _) in use_def.definitions_with_usage() {
+ let ScopedPlaceId::Symbol(symbol) = definition.place(db) else {
+ continue;
+ };
+ let kind = definition.kind(db);
+ if matches!(
+ kind,
+ DefinitionKind::LoopHeader(_) | DefinitionKind::NestedBindings(_)
+ ) {
+ continue;
+ }
+ if !in_selection(kind.target_range(&context.parsed)) {
+ continue;
+ }
+ let symbol = table.symbol(symbol);
+ if symbol.is_global() || symbol.is_nonlocal() {
+ return Err(refuse(format!(
+ "`{}` is declared `global` or `nonlocal`, so assigning it inside a new function would need the same declaration",
+ symbol.name()
+ )));
+ }
+ bound.push((symbol.name().clone(), definition));
+ }
+ let bound_names: FxHashSet<&Name> = bound.iter().map(|(name, _)| name).collect();
+
+ // what the statements read from the enclosing scope, in the order they read it
+ let reads = collect_reads(
+ index,
+ scope,
+ selection.statements.iter().copied(),
+ TextRange::empty(TextSize::default()),
+ );
+ let receiver = method.as_ref().map(|method| &method.receiver);
+ let mut parameters: Vec = Vec::new();
+ for read in &reads.names {
+ if receiver.is_some_and(|receiver| *receiver == read.name.id) {
+ if bound_names.contains(&read.name.id) {
+ return Err(refuse(format!(
+ "the statements assign the receiver `{}`",
+ read.name.id
+ )));
+ }
+ continue;
+ }
+ if read.nested {
+ if scope.is_global() {
+ if bound_names.contains(&read.name.id) {
+ return Err(refuse(format!(
+ "a function defined in the statements reads `{}`, which would become a variable of the new function",
+ read.name.id
+ )));
+ }
+ continue;
+ }
+ let bound_outside = bindings_outside(context, index, scope, &read.name.id, selected);
+ if bound_outside && bound_names.contains(&read.name.id) {
+ return Err(refuse(format!(
+ "a function defined in the statements reads `{}`, which is assigned both inside and outside them",
+ read.name.id
+ )));
+ }
+ if bound_outside && !parameters.contains(&read.name.id) {
+ parameters.push(read.name.id.clone());
+ }
+ continue;
+ }
+ let reaching = reaching_definitions(db, index, scope, read.name);
+ let from_outside = reaching
+ .definitions
+ .iter()
+ .any(|definition| !in_selection(definition.kind(db).target_range(&context.parsed)));
+ if !from_outside {
+ continue;
+ }
+ // a module's variable is read where it is when nothing in the statements
+ // makes it local to the new function
+ if scope.is_global() && !bound_names.contains(&read.name.id) {
+ continue;
+ }
+ if reaching.maybe_unbound {
+ return Err(refuse(format!(
+ "`{}` may be unbound where the new function would be called",
+ read.name.id
+ )));
+ }
+ if !parameters.contains(&read.name.id) {
+ parameters.push(read.name.id.clone());
+ }
+ }
+
+ // what the statements bind that is read after them
+ let mut returned: Vec = Vec::new();
+ let later_reads = match enclosing {
+ Some(function) => collect_reads(index, scope, function.body.iter(), selected),
+ None => Reads::default(),
+ };
+ for (name, definition) in &bound {
+ if returned.contains(name) {
+ continue;
+ }
+ let read_after = if scope.is_global() {
+ // a module's variables are its attributes, read by whoever imports it
+ true
+ } else {
+ later_reads.names.iter().any(|read| {
+ read.name.id == *name
+ && (read.nested
+ || reaching_definitions(db, index, scope, read.name)
+ .definitions
+ .iter()
+ .any(|reached| reached == definition))
+ })
+ };
+ if !read_after {
+ continue;
+ }
+ let symbol = table.symbol_id(name).map(|id| table.symbol(id));
+ if symbol.is_some_and(ty_python_core::symbol::Symbol::is_declared) {
+ return Err(refuse(format!(
+ "`{name}` is declared in the statements and read after them, and a declaration can't be returned"
+ )));
+ }
+ if !parameters.contains(name) && !always_binds(&selection.statements, name) {
+ return Err(refuse(format!(
+ "`{name}` is read after the statements but not assigned on every path through them"
+ )));
+ }
+ returned.push(name.clone());
+ }
+
+ // the new definition
+ let line_ending = context.line_ending();
+ let indent_unit = context.indent_unit();
+ let (definition_indent, insert_at, blank_lines) = match enclosing {
+ Some(function) => (
+ context.indentation(function).to_string(),
+ leading_comments_start(source, function.start()),
+ if method.is_some() || !scope_is_top_level(index, scope) {
+ 1
+ } else {
+ 2
+ },
+ ),
+ None => (
+ String::new(),
+ leading_comments_start(source, ancestors[0].start()),
+ 2,
+ ),
+ };
+ let body_indent = format!("{definition_indent}{indent_unit}");
+ let selection_indent = context.indentation(first).to_string();
+ let lines = TextRange::new(
+ source.line_start(first.start()),
+ source.line_end(last.end()),
+ );
+ let body = reindent(context, lines, &selection_indent, &body_indent);
+
+ let mut signature = Vec::new();
+ if let Some(method) = &method {
+ signature.push(method.receiver.to_string());
+ }
+ signature.extend(parameters.iter().map(ToString::to_string));
+ let mut definition = String::new();
+ if matches!(
+ method,
+ Some(Method {
+ kind: MethodKind::Class,
+ ..
+ })
+ ) {
+ let _ = write!(definition, "{definition_indent}@classmethod{line_ending}");
+ }
+ let _ = write!(
+ definition,
+ "{definition_indent}{async_}def {name}({signature}):{line_ending}{body}{line_ending}",
+ async_ = if scan.awaits { "async " } else { "" },
+ signature = signature.join(", "),
+ );
+ if !returned.is_empty() {
+ let _ = write!(
+ definition,
+ "{body_indent}return {}{line_ending}",
+ join_names(&returned)
+ );
+ }
+ for _ in 0..blank_lines {
+ definition.push_str(line_ending);
+ }
+
+ // the call
+ let callee = match &method {
+ Some(method) => format!("{}.{name}", method.receiver),
+ None => name,
+ };
+ let call = format!(
+ "{await_}{callee}({arguments})",
+ await_ = if scan.awaits { "await " } else { "" },
+ arguments = parameters
+ .iter()
+ .map(ToString::to_string)
+ .collect::>()
+ .join(", "),
+ );
+ let call_statement = if returned.is_empty() {
+ format!("{selection_indent}{call}")
+ } else {
+ format!("{selection_indent}{} = {call}", join_names(&returned))
+ };
+
+ // a definition written directly above the statements it replaces is one edit,
+ // since two edits starting at one offset have no order a client must keep
+ let edits = if insert_at == lines.start() {
+ vec![Edit::range_replacement(
+ format!("{definition}{call_statement}"),
+ lines,
+ )]
+ } else {
+ vec![
+ Edit::insertion(definition, insert_at),
+ Edit::range_replacement(call_statement, lines),
+ ]
+ };
+ Ok(Plan { title, edits })
+}
+
+/// Whole statements, siblings in one suite, that the selection covers.
+struct Selection<'a> {
+ statements: Vec<&'a Stmt>,
+}
+
+fn select_statements<'a>(
+ context: &'a RefactorContext<'_>,
+ range: TextRange,
+) -> Option> {
+ let source = context.source();
+ let text = source.get(range.to_std_range())?;
+ let trimmed = text.trim_whitespace();
+ if trimmed.is_empty() {
+ return None;
+ }
+ let start =
+ range.start() + TextSize::try_from(text.len() - text.trim_whitespace_start().len()).ok()?;
+ let range = TextRange::at(start, TextSize::try_from(trimmed.len()).ok()?);
+
+ let mut suite: &[Stmt] = context.parsed.suite();
+ loop {
+ let overlapping: Vec<&Stmt> = suite
+ .iter()
+ .filter(|stmt| {
+ stmt.range()
+ .intersect(range)
+ .is_some_and(|overlap| !overlap.is_empty())
+ || range.contains_range(stmt.range())
+ })
+ .collect();
+ match overlapping.as_slice() {
+ [] => return None,
+ [only] if !covers(source, range, only, only) => {
+ // the selection is inside one statement: look in its suites
+ let nested = nested_suites(only).into_iter().find(|nested| {
+ nested
+ .iter()
+ .any(|stmt| stmt.range().intersect(range).is_some())
+ })?;
+ suite = nested;
+ }
+ statements => {
+ let (first, last) = (statements[0], statements[statements.len() - 1]);
+ if !covers(source, range, first, last) {
+ return None;
+ }
+ if !overlapping.iter().all(|stmt| context.owns_its_lines(stmt)) {
+ return None;
+ }
+ return Some(Selection {
+ statements: overlapping,
+ });
+ }
+ }
+ }
+}
+
+/// Whether `range` starts at `first` and ends at `last`, allowing the rest of
+/// `last`'s line (a trailing comment) to be selected too.
+fn covers(source: &str, range: TextRange, first: &Stmt, last: &Stmt) -> bool {
+ range.start() <= first.start()
+ && source.line_start(range.start()) == source.line_start(first.start())
+ && range.end() >= last.end()
+ && range.end() <= source.line_end(last.end())
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum MethodKind {
+ Instance,
+ Class,
+}
+
+struct Method {
+ receiver: Name,
+ kind: MethodKind,
+}
+
+impl Method {
+ fn of(context: &RefactorContext<'_>, function: &ast::StmtFunctionDef) -> Result {
+ let mut kind = MethodKind::Instance;
+ for decorator in &function.decorator_list {
+ let Expr::Name(name) = &decorator.expression else {
+ continue;
+ };
+ match name.id.as_str() {
+ "staticmethod" | "static" => {
+ return Err(Refusal::refused(
+ "Extract method",
+ "a static method has no receiver to call a new method through",
+ ));
+ }
+ "classmethod" => kind = MethodKind::Class,
+ _ => {}
+ }
+ }
+ let _ = context;
+ let receiver = function
+ .parameters
+ .posonlyargs
+ .iter()
+ .chain(&function.parameters.args)
+ .next()
+ .map(|parameter| parameter.parameter.name.id.clone())
+ .ok_or_else(|| {
+ Refusal::refused("Extract method", "the method has no receiver parameter")
+ })?;
+ Ok(Method { receiver, kind })
+ }
+}
+
+/// `return`, `yield`, `break` and `continue` that leave the statements, and
+/// statements that only mean something where they are.
+#[derive(Default)]
+struct ControlFlowScan {
+ loop_depth: usize,
+ awaits: bool,
+ refusal: Option,
+}
+
+impl<'a> SourceOrderVisitor<'a> for ControlFlowScan {
+ fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
+ if self.refusal.is_some() {
+ return TraversalSignal::Skip;
+ }
+ let refusal = match node {
+ // a nested scope's control flow is its own
+ AnyNodeRef::StmtFunctionDef(_)
+ | AnyNodeRef::StmtClassDef(_)
+ | AnyNodeRef::ExprLambda(_) => return TraversalSignal::Skip,
+ AnyNodeRef::StmtReturn(_) => Some("the statements `return`"),
+ AnyNodeRef::ExprYield(_) | AnyNodeRef::ExprYieldFrom(_) => {
+ Some("the statements `yield`")
+ }
+ AnyNodeRef::StmtGlobal(_) | AnyNodeRef::StmtNonlocal(_) => {
+ Some("the statements declare `global` or `nonlocal`")
+ }
+ AnyNodeRef::StmtBreak(stmt) if self.loop_depth == 0 || stmt.value.is_some() => {
+ Some("the statements `break` out of a loop around them")
+ }
+ AnyNodeRef::StmtContinue(_) if self.loop_depth == 0 => {
+ Some("the statements `continue` a loop around them")
+ }
+ AnyNodeRef::StmtLet(_) => {
+ Some("a `let` pattern statement leaves its suite when it does not match")
+ }
+ AnyNodeRef::StmtDelete(_) => Some("the statements delete a variable"),
+ AnyNodeRef::ExprAwait(_) => {
+ self.awaits = true;
+ None
+ }
+ AnyNodeRef::StmtWith(stmt) => {
+ self.awaits |= stmt.is_async;
+ None
+ }
+ AnyNodeRef::Comprehension(comprehension) => {
+ self.awaits |= comprehension.is_async;
+ None
+ }
+ // a loop's `break` and `continue` are its body's; the ones in its `else`
+ // belong to the loop around it
+ AnyNodeRef::StmtFor(stmt) => {
+ self.awaits |= stmt.is_async;
+ self.visit_expr(&stmt.target);
+ self.visit_expr(&stmt.iter);
+ self.in_loop(&stmt.body);
+ self.visit_body(&stmt.orelse);
+ return TraversalSignal::Skip;
+ }
+ AnyNodeRef::StmtWhile(stmt) => {
+ self.visit_expr(&stmt.test);
+ self.in_loop(&stmt.body);
+ self.visit_body(&stmt.orelse);
+ return TraversalSignal::Skip;
+ }
+ _ => None,
+ };
+ if let Some(refusal) = refusal {
+ self.refusal = Some(refusal.to_string());
+ return TraversalSignal::Skip;
+ }
+ TraversalSignal::Traverse
+ }
+}
+
+impl ControlFlowScan {
+ fn in_loop(&mut self, body: &[Stmt]) {
+ self.loop_depth += 1;
+ self.visit_body(body);
+ self.loop_depth -= 1;
+ }
+}
+
+struct Read<'a> {
+ name: &'a ast::ExprName,
+ /// The load is in a scope nested in the enclosing one, so it runs whenever
+ /// that scope runs rather than where it is written.
+ nested: bool,
+}
+
+#[derive(Default)]
+struct Reads<'a> {
+ names: Vec>,
+}
+
+/// The loads, in `statements` but not in `excluded`, of names that resolve to `scope`.
+fn collect_reads<'a>(
+ index: &SemanticIndex<'_>,
+ scope: FileScopeId,
+ statements: impl Iterator
- ,
+ excluded: TextRange,
+) -> Reads<'a> {
+ struct Collector<'a, 'i, 'db> {
+ index: &'i SemanticIndex<'db>,
+ scope: FileScopeId,
+ excluded: TextRange,
+ reads: Vec>,
+ }
+ impl<'a> SourceOrderVisitor<'a> for Collector<'a, '_, '_> {
+ fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
+ if !self.excluded.is_empty() && self.excluded.contains_range(node.range()) {
+ return TraversalSignal::Skip;
+ }
+ if let AnyNodeRef::ExprName(name) = node
+ && name.ctx.is_load()
+ && let Some(use_scope) = self
+ .index
+ .try_expression_scope_id(&ast::ExprRef::Name(name))
+ && resolving_scope(self.index, use_scope, &name.id) == Some(self.scope)
+ {
+ self.reads.push(Read {
+ name,
+ nested: use_scope != self.scope,
+ });
+ }
+ TraversalSignal::Traverse
+ }
+ }
+ let mut collector = Collector {
+ index,
+ scope,
+ excluded,
+ reads: Vec::new(),
+ };
+ for statement in statements {
+ walk_node(&mut collector, AnyNodeRef::from(statement));
+ }
+ Reads {
+ names: collector.reads,
+ }
+}
+
+/// Whether `name` has a binding in `scope` outside `selected`.
+fn bindings_outside(
+ context: &RefactorContext<'_>,
+ index: &SemanticIndex<'_>,
+ scope: FileScopeId,
+ name: &str,
+ selected: TextRange,
+) -> bool {
+ let db = context.db;
+ let Some(symbol) = index.place_table(scope).symbol_id(name) else {
+ return false;
+ };
+ index
+ .use_def_map(scope)
+ .definitions_with_usage()
+ .any(|(_, definition, _)| {
+ definition.place(db) == ScopedPlaceId::Symbol(symbol)
+ && !matches!(definition.kind(db), DefinitionKind::LoopHeader(_))
+ && !selected.contains_range(definition.kind(db).target_range(&context.parsed))
+ })
+}
+
+/// Whether one of `statements` unconditionally binds `name` to a value.
+fn always_binds(statements: &[&Stmt], name: &Name) -> bool {
+ statements.iter().any(|statement| match statement {
+ Stmt::Assign(assign) => assign
+ .targets
+ .iter()
+ .any(|target| target_binds(target, name)),
+ Stmt::AugAssign(assign) => target_binds(&assign.target, name),
+ Stmt::AnnAssign(assign) => assign.value.is_some() && target_binds(&assign.target, name),
+ Stmt::FunctionDef(function) => function.name.id == *name,
+ Stmt::ClassDef(class) => class.name.id == *name,
+ Stmt::Import(import) => import.names.iter().any(|alias| {
+ alias.asname.as_ref().map_or_else(
+ || alias.name.id.split('.').next() == Some(name.as_str()),
+ |asname| asname.id == *name,
+ )
+ }),
+ Stmt::ImportFrom(import) => import
+ .names
+ .iter()
+ .any(|alias| alias.asname.as_ref().unwrap_or(&alias.name).id == *name),
+ _ => false,
+ })
+}
+
+fn target_binds(target: &Expr, name: &Name) -> bool {
+ match target {
+ Expr::Name(target) => target.id == *name,
+ Expr::Tuple(tuple) => tuple.elts.iter().any(|element| target_binds(element, name)),
+ Expr::List(list) => list.elts.iter().any(|element| target_binds(element, name)),
+ Expr::Starred(starred) => target_binds(&starred.value, name),
+ _ => false,
+ }
+}
+
+fn join_names(names: &[Name]) -> String {
+ names
+ .iter()
+ .map(ToString::to_string)
+ .collect::>()
+ .join(", ")
+}
+
+/// Whether `scope` is a function defined directly in the module.
+fn scope_is_top_level(index: &SemanticIndex<'_>, scope: FileScopeId) -> bool {
+ index.scope(scope).parent() == Some(FileScopeId::global())
+}
+
+/// The lines of `lines`, moved from `from` indentation to `to`. A line that
+/// starts inside a multi-line string is part of the string and is left alone.
+fn reindent(context: &RefactorContext<'_>, lines: TextRange, from: &str, to: &str) -> String {
+ let source = context.source();
+ let tokens = context.parsed.tokens();
+ let mut result = String::new();
+ let mut offset = lines.start();
+ let mut first = true;
+ while offset <= lines.end() {
+ let line_end = source.line_end(offset);
+ let full_end = source.full_line_end(offset).min(lines.end());
+ let line = &source[TextRange::new(offset, line_end.min(lines.end()))];
+ // tokens are in source order, so the one that could span `offset` is the
+ // first that ends after it
+ let inside_token = !first
+ && tokens
+ .get(tokens.partition_point(|token| token.end() <= offset))
+ .is_some_and(|token| token.start() < offset);
+ if !first {
+ result.push_str(context.line_ending());
+ }
+ first = false;
+ if inside_token {
+ result.push_str(line);
+ } else if line.trim_whitespace().is_empty() {
+ // blank lines carry no indentation
+ } else {
+ let indentation = leading_indentation(line);
+ let kept = if indentation.starts_with(from) {
+ &line[from.len()..]
+ } else {
+ &line[indentation.len()..]
+ };
+ result.push_str(to);
+ result.push_str(kept);
+ }
+ if full_end >= lines.end() {
+ break;
+ }
+ offset = full_end;
+ }
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use insta::assert_snapshot;
+
+ use crate::refactor::RefactorKind;
+ use crate::refactor::test_support::RefactorTest;
+
+ fn extract(source: &str) -> String {
+ RefactorTest::python(source).apply(RefactorKind::ExtractFunction)
+ }
+
+ #[test]
+ fn passes_locals_and_returns_what_is_read_after() {
+ assert_snapshot!(extract(
+ "
+ def report(orders, tax):
+ total = 0
+ for order in orders:
+ total += order.price * tax
+ print(total)
+ ",
+ ), @"
+ Extract function `extracted`
+ ---
+
+ def extracted(orders, tax):
+ total = 0
+ for order in orders:
+ total += order.price * tax
+ return total
+
+
+ def report(orders, tax):
+ total = extracted(orders, tax)
+ print(total)
+ ");
+ }
+
+ #[test]
+ fn method_becomes_a_method_called_through_self() {
+ assert_snapshot!(extract(
+ "
+ class Cart:
+ def checkout(self, discount):
+ price = self.total() - discount
+ self.charge(price)
+
+ def total(self) -> int: ...
+ def charge(self, amount): ...
+ ",
+ ), @"
+ Extract method `extracted`
+ ---
+
+ class Cart:
+ def extracted(self, discount):
+ price = self.total() - discount
+ self.charge(price)
+
+ def checkout(self, discount):
+ self.extracted(discount)
+
+ def total(self) -> int: ...
+ def charge(self, amount): ...
+ ");
+ }
+
+ #[test]
+ fn classmethod_stays_a_classmethod() {
+ assert_snapshot!(extract(
+ "
+ class Config:
+ @classmethod
+ def load(cls, path):
+ text = open(path).read()
+ return cls(text)
+ ",
+ ), @"
+ Extract method `extracted`
+ ---
+
+ class Config:
+ @classmethod
+ def extracted(cls, path):
+ text = open(path).read()
+ return text
+
+ @classmethod
+ def load(cls, path):
+ text = cls.extracted(path)
+ return cls(text)
+ ");
+ }
+
+ #[test]
+ fn static_method_is_refused() {
+ assert_snapshot!(extract(
+ "
+ class A:
+ @staticmethod
+ def f(x):
+ print(x)
+ ",
+ ), @"refused: Extract method (a static method has no receiver to call a new method through)");
+ }
+
+ #[test]
+ fn return_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(x):
+ if x:
+ return 1
+ return 2
+ ",
+ ), @"refused: Extract function `extracted` (the statements `return`)");
+ }
+
+ #[test]
+ fn break_out_of_an_enclosing_loop_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(items):
+ for item in items:
+ if item:
+ break
+ ",
+ ), @"refused: Extract function `extracted` (the statements `break` out of a loop around them)");
+ }
+
+ #[test]
+ fn break_inside_the_selection_is_allowed() {
+ assert_snapshot!(extract(
+ "
+ def f(items):
+ for item in items:
+ if item:
+ break
+ ",
+ ), @"
+ Extract function `extracted`
+ ---
+
+ def extracted(items):
+ for item in items:
+ if item:
+ break
+
+
+ def f(items):
+ extracted(items)
+ ");
+ }
+
+ #[test]
+ fn break_in_a_loop_else_leaves_the_loop_around_it() {
+ assert_snapshot!(extract(
+ "
+ def f(items, groups):
+ for group in groups:
+ for item in items:
+ pass
+ else:
+ break
+ ",
+ ), @"refused: Extract function `extracted` (the statements `break` out of a loop around them)");
+ }
+
+ #[test]
+ fn variable_assigned_on_some_paths_and_read_after_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(flag):
+ if flag:
+ x = 1
+ print(x)
+ ",
+ ), @"refused: Extract function `extracted` (`x` is read after the statements but not assigned on every path through them)");
+ }
+
+ #[test]
+ fn rebound_variable_is_passed_and_returned() {
+ assert_snapshot!(extract(
+ "
+ def f(count):
+ count = count + 1
+ return count
+ ",
+ ), @"
+ Extract function `extracted`
+ ---
+
+ def extracted(count):
+ count = count + 1
+ return count
+
+
+ def f(count):
+ count = extracted(count)
+ return count
+ ");
+ }
+
+ #[test]
+ fn module_level_statements_return_what_they_bind() {
+ assert_snapshot!(extract(
+ "
+ import sys
+
+ name = sys.argv[0]
+ print(name)
+ ",
+ ), @"
+ Extract function `extracted`
+ ---
+
+ import sys
+
+ def extracted():
+ name = sys.argv[0]
+ print(name)
+ return name
+
+
+ name = extracted()
+ ");
+ }
+
+ #[test]
+ fn nested_function_goes_inside_the_outer_function() {
+ assert_snapshot!(extract(
+ "
+ def outer(a):
+ def inner(b):
+ print(a, b)
+ return inner
+ ",
+ ), @"
+ Extract function `extracted`
+ ---
+
+ def outer(a):
+ def extracted(b):
+ print(a, b)
+
+ def inner(b):
+ extracted(b)
+ return inner
+ ");
+ }
+
+ #[test]
+ fn awaits_make_an_async_function() {
+ assert_snapshot!(extract(
+ "
+ async def f(client):
+ response = await client.get()
+ return response
+ ",
+ ), @"
+ Extract function `extracted`
+ ---
+
+ async def extracted(client):
+ response = await client.get()
+ return response
+
+
+ async def f(client):
+ response = await extracted(client)
+ return response
+ ");
+ }
+
+ #[test]
+ fn multiline_string_is_not_reindented() {
+ assert_snapshot!(extract(
+ r#"
+ def f():
+ if True:
+ text = """
+ first
+ second
+ """
+ print(text)
+ "#,
+ ), @r#"
+ Extract function `extracted`
+ ---
+
+ def extracted():
+ text = """
+ first
+ second
+ """
+ return text
+
+
+ def f():
+ if True:
+ text = extracted()
+ print(text)
+ "#);
+ }
+
+ #[test]
+ fn part_of_a_statement_is_not_offered() {
+ assert_snapshot!(extract(
+ "
+ def f(a):
+ print(a)
+ ",
+ ), @"not offered");
+ }
+
+ #[test]
+ fn global_declaration_is_refused() {
+ assert_snapshot!(extract(
+ "
+ counter = 0
+
+ def f():
+ global counter
+ counter = counter + 1
+ ",
+ ), @"refused: Extract function `extracted` (`counter` is declared `global` or `nonlocal`, so assigning it inside a new function would need the same declaration)");
+ }
+}
diff --git a/crates/ty_ide/src/refactor/extract_variable.rs b/crates/ty_ide/src/refactor/extract_variable.rs
new file mode 100644
index 0000000000..361597f002
--- /dev/null
+++ b/crates/ty_ide/src/refactor/extract_variable.rs
@@ -0,0 +1,720 @@
+//! Extract Variable and Introduce Constant: bind a selected expression to a
+//! new name, and read the name where the expression was.
+//!
+//! A variable is bound directly above the statement the expression is part of,
+//! so the expression is now evaluated before the rest of that statement. That is
+//! only the same program when the expression was evaluated exactly once whenever
+//! the statement ran, and nothing with an effect was evaluated before it — an
+//! expression in a loop's condition, a conditional branch, a lambda, or after a
+//! call is refused.
+//!
+//! A constant is bound at the top level of the module, before the definition
+//! that uses it, so it is evaluated once at import — whether or not the
+//! expression it was made from would have been evaluated at all. That is only
+//! the same program for an expression that is made of literals, does not build
+//! anything mutable, and cannot raise; see [`is_constant`].
+
+use ruff_diagnostics::Edit;
+use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal, walk_node};
+use ruff_python_ast::{self as ast, AnyNodeRef, Expr, Stmt, UnaryOp};
+use ruff_python_trivia::PythonWhitespace;
+use ruff_source_file::LineRanges;
+use ruff_text_size::{Ranged, TextRange, TextSize};
+use ty_python_semantic::types::ide_support::folds_to_a_literal;
+
+use super::evaluation::{NotOnce, Placement, placement_in_statement};
+use super::hazards::{contains_named_expression, context_dependent_construct};
+use super::names::{fresh_name, names_in_file, suggest_for_expression};
+use super::text::{leading_comments_start, statement_ancestors, suite_of};
+use super::{Plan, RefactorContext, Refusal};
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum Target {
+ Variable,
+ Constant,
+}
+
+pub(super) fn plan(
+ context: &RefactorContext<'_>,
+ range: TextRange,
+ target: Target,
+) -> Result {
+ let Some(selection) = selected_expression(context, range) else {
+ return Err(Refusal::NotApplicable);
+ };
+ let expr = selection.expr;
+ if target == Target::Constant && !is_constant(context, expr) {
+ return Err(Refusal::NotApplicable);
+ }
+ let module = context.parsed.suite();
+ let ancestors = statement_ancestors(module, expr.range());
+ let Some(&statement) = ancestors.last() else {
+ return Err(Refusal::NotApplicable);
+ };
+ // the statement already is the expression on its own
+ if matches!(statement, Stmt::Expr(stmt) if stmt.value.range() == expr.range()) {
+ return Err(Refusal::NotApplicable);
+ }
+
+ let names = names_in_file(context);
+ let version = context.file.python_version(context.db);
+ let base = suggest_for_expression(expr, version.minor);
+ let (name, title) = match target {
+ Target::Variable => {
+ let name = fresh_name(&base, &names);
+ let title = format!("Extract variable `{name}`");
+ (name, title)
+ }
+ Target::Constant => {
+ let name = fresh_name(&base.to_uppercase(), &names);
+ let title = format!("Introduce constant `{name}`");
+ (name, title)
+ }
+ };
+ let refuse = |reason: String| Refusal::refused(title.clone(), reason);
+
+ if let Some(reason) = context_dependent_construct(context, AnyNodeRef::from(expr)) {
+ return Err(refuse(format!("the expression can't be moved: {reason}")));
+ }
+ if contains_named_expression(expr) {
+ return Err(refuse("the expression binds a name with `:=`".to_string()));
+ }
+
+ let placement = placement_in_statement(statement, AnyNodeRef::from(expr));
+ let expression_text = expression_source(context, expr);
+ let line_ending = context.line_ending();
+
+ let insertion = match target {
+ Target::Variable => {
+ match placement {
+ Placement::Once {
+ effects_before: false,
+ } => {}
+ Placement::Once {
+ effects_before: true,
+ } => {
+ return Err(refuse(
+ "something with effects is evaluated before the expression, which would now run after it"
+ .to_string(),
+ ));
+ }
+ Placement::NotOnce(reason) => {
+ return Err(refuse(format!(
+ "the expression can't be evaluated before its statement: {}",
+ reason.describe()
+ )));
+ }
+ }
+ if ancestors
+ .iter()
+ .rev()
+ .skip(1)
+ .find(|stmt| matches!(stmt, Stmt::FunctionDef(_) | Stmt::ClassDef(_)))
+ .is_some_and(|stmt| stmt.is_class_def_stmt())
+ {
+ return Err(refuse(
+ "a variable bound in a class body would become an attribute of the class"
+ .to_string(),
+ ));
+ }
+ if suite_of(module, statement).is_none()
+ || ruff_python_trivia::indentation_at_offset(statement.start(), context.source())
+ .is_none()
+ {
+ return Err(refuse(
+ "the statement shares its line with the one it belongs to".to_string(),
+ ));
+ }
+ let indentation = context.indentation(statement);
+ Edit::insertion(
+ format!("{indentation}{name} = {expression_text}{line_ending}"),
+ context.source().line_start(statement.start()),
+ )
+ }
+ Target::Constant => {
+ if let Placement::NotOnce(reason @ (NotOnce::Annotation | NotOnce::Opaque)) = placement
+ {
+ return Err(refuse(format!(
+ "the expression can't be moved to the module: {}",
+ reason.describe()
+ )));
+ }
+ let top_level = ancestors[0];
+ let at = leading_comments_start(context.source(), top_level.start());
+ let separation = if matches!(top_level, Stmt::FunctionDef(_) | Stmt::ClassDef(_)) {
+ format!("{line_ending}{line_ending}")
+ } else {
+ String::new()
+ };
+ Edit::insertion(
+ format!("{name} = {expression_text}{line_ending}{separation}"),
+ at,
+ )
+ }
+ };
+
+ Ok(Plan {
+ title,
+ edits: vec![insertion, Edit::range_replacement(name, selection.replaced)],
+ })
+}
+
+struct Selection<'a> {
+ expr: &'a Expr,
+ /// The range the name replaces: the expression, with the parentheses the
+ /// selection included.
+ replaced: TextRange,
+}
+
+/// The expression the selection covers exactly, ignoring surrounding
+/// whitespace and allowing parentheses around it.
+fn selected_expression<'a>(
+ context: &'a RefactorContext<'_>,
+ range: TextRange,
+) -> Option> {
+ struct Finder<'a, 'c, 'db> {
+ context: &'c RefactorContext<'db>,
+ range: TextRange,
+ parents: Vec>,
+ found: Option>,
+ }
+ impl<'a> SourceOrderVisitor<'a> for Finder<'a, '_, '_> {
+ fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
+ // `leave_node` runs for skipped nodes too, so every node is pushed
+ self.parents.push(node);
+ // a node that is not around the selection can still be the expression
+ // it selects, when the selection includes the expression's parentheses
+ if self.found.is_some()
+ || !(node.range().contains_range(self.range)
+ || self.range.contains_range(node.range()))
+ {
+ return TraversalSignal::Skip;
+ }
+ TraversalSignal::Traverse
+ }
+
+ fn leave_node(&mut self, _node: AnyNodeRef<'a>) {
+ self.parents.pop();
+ }
+
+ fn visit_expr(&mut self, expr: &'a Expr) {
+ if self.found.is_some() {
+ return;
+ }
+ if let Some(parent) = self.parents.last().copied()
+ && (expr.range() == self.range
+ || self.context.parenthesized_range(expr, parent) == self.range)
+ {
+ self.found = Some(Selection {
+ expr,
+ replaced: self.range,
+ });
+ return;
+ }
+ ruff_python_ast::visitor::source_order::walk_expr(self, expr);
+ }
+ }
+
+ let source = context.source();
+ let selected = source.get(range.to_std_range())?;
+ let leading = selected.len() - selected.trim_whitespace_start().len();
+ let trimmed = selected.trim_whitespace();
+ if trimmed.is_empty() {
+ return None;
+ }
+ let start = range.start() + TextSize::try_from(leading).ok()?;
+ let trimmed = TextRange::at(start, TextSize::try_from(trimmed.len()).ok()?);
+
+ let mut finder = Finder {
+ context,
+ range: trimmed,
+ parents: Vec::new(),
+ found: None,
+ };
+ walk_node(&mut finder, context.parsed.syntax().into());
+ let selection = finder.found?;
+ is_extractable(selection.expr).then_some(selection)
+}
+
+/// Whether `expr` is a value that can be bound to a name: it is read rather
+/// than assigned, and it means something outside its position.
+fn is_extractable(expr: &Expr) -> bool {
+ match expr {
+ Expr::Name(ast::ExprName { ctx, .. })
+ | Expr::Attribute(ast::ExprAttribute { ctx, .. })
+ | Expr::Subscript(ast::ExprSubscript { ctx, .. })
+ | Expr::List(ast::ExprList { ctx, .. })
+ | Expr::Tuple(ast::ExprTuple { ctx, .. }) => ctx.is_load(),
+ Expr::Starred(_)
+ | Expr::Slice(_)
+ | Expr::IpyEscapeCommand(_)
+ | Expr::CallableType(_)
+ | Expr::ProtocolType(_)
+ | Expr::ProtocolMethod(_)
+ | Expr::Statement(_) => false,
+ _ => true,
+ }
+}
+
+/// The source of `expr` as the value of an assignment, parenthesized where its
+/// text would not parse there on its own.
+fn expression_source(context: &RefactorContext<'_>, expr: &Expr) -> String {
+ let text = context.expression_text(expr);
+ let bracketed = matches!(
+ expr,
+ Expr::List(_)
+ | Expr::ListComp(_)
+ | Expr::Dict(_)
+ | Expr::DictComp(_)
+ | Expr::Set(_)
+ | Expr::SetComp(_)
+ ) || matches!(expr, Expr::Tuple(tuple) if tuple.parenthesized);
+ let unparenthesized_generator =
+ matches!(expr, Expr::Generator(generator) if !generator.parenthesized);
+ if unparenthesized_generator || (!bracketed && context.is_multiline(expr.range())) {
+ format!("({text})")
+ } else {
+ text.to_string()
+ }
+}
+
+/// Whether `expr` is made only of literals and evaluates to an immutable value without raising.
+///
+/// Made only of literals so that the constant, read at the top of the module, reads nothing that
+/// might not be bound there yet.
+///
+/// Without raising because a constant is evaluated as the module loads, and the expression it is
+/// made from need not have been evaluated at all where it was written — `1 / 0` in a branch that
+/// never runs, or an expression under an `assert` that python skips with `-O`. Hoisting one of
+/// those would turn a program that runs into one that fails to import.
+///
+/// Whether an operation over literals raises is not something to read off its spelling, so each
+/// one is asked of the checker instead: ty folds it to the literal it produces exactly when it
+/// has carried it out, which makes a folded type the proof that evaluating it yields a value.
+/// `60 * 60 * 24` folds and is hoisted; `1 / 0`, `1 % 0`, `-"a"` and `1 + "a"` do not and are not.
+fn is_constant(context: &RefactorContext<'_>, expr: &Expr) -> bool {
+ match expr {
+ Expr::StringLiteral(_)
+ | Expr::BytesLiteral(_)
+ | Expr::NumberLiteral(_)
+ | Expr::BooleanLiteral(_)
+ | Expr::NoneLiteral(_)
+ | Expr::EllipsisLiteral(_) => true,
+ Expr::UnaryOp(unary) => {
+ matches!(
+ unary.op,
+ UnaryOp::USub | UnaryOp::UAdd | UnaryOp::Invert | UnaryOp::Not
+ ) && is_constant(context, &unary.operand)
+ && folds_to_a_literal(&context.model, expr)
+ }
+ Expr::BinOp(bin_op) => {
+ !matches!(bin_op.op, ast::Operator::Coalesce | ast::Operator::Result)
+ && is_constant(context, &bin_op.left)
+ && is_constant(context, &bin_op.right)
+ && folds_to_a_literal(&context.model, expr)
+ }
+ // a tuple display of constants builds a tuple, which cannot fail
+ Expr::Tuple(tuple) => {
+ tuple.ctx.is_load()
+ && !tuple.is_anon_named_tuple
+ && !tuple.is_anon_named_tuple_value
+ && tuple.callable_shape.is_none()
+ && !tuple.is_parameter_shape
+ && tuple
+ .elts
+ .iter()
+ .all(|element| is_constant(context, element))
+ }
+ _ => false,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use insta::assert_snapshot;
+
+ use crate::refactor::RefactorKind;
+ use crate::refactor::test_support::RefactorTest;
+
+ fn extract(source: &str) -> String {
+ RefactorTest::python(source).apply(RefactorKind::ExtractVariable)
+ }
+
+ fn constant(source: &str) -> String {
+ RefactorTest::python(source).apply(RefactorKind::IntroduceConstant)
+ }
+
+ #[test]
+ fn extracts_above_the_statement() {
+ assert_snapshot!(extract(
+ "
+ def f(items):
+ print(len(items) + 1)
+ ",
+ ), @"
+ Extract variable `value`
+ ---
+
+ def f(items):
+ value = len(items) + 1
+ print(value)
+ ");
+ }
+
+ #[test]
+ fn name_comes_from_the_call() {
+ assert_snapshot!(extract(
+ "
+ def f(user):
+ return user.get_name().upper()
+ ",
+ ), @"
+ Extract variable `name`
+ ---
+
+ def f(user):
+ name = user.get_name()
+ return name.upper()
+ ");
+ }
+
+ #[test]
+ fn name_avoids_every_name_in_the_file() {
+ assert_snapshot!(extract(
+ "
+ value = 1
+
+ def f(a, b):
+ return a * b + value
+ ",
+ ), @"
+ Extract variable `value_1`
+ ---
+
+ value = 1
+
+ def f(a, b):
+ value_1 = a * b
+ return value_1 + value
+ ");
+ }
+
+ #[test]
+ fn selection_may_include_parentheses() {
+ assert_snapshot!(extract(
+ "
+ def f(a, b):
+ return (a + b) * 2
+ ",
+ ), @"
+ Extract variable `value`
+ ---
+
+ def f(a, b):
+ value = a + b
+ return value * 2
+ ");
+ }
+
+ #[test]
+ fn loop_condition_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(queue):
+ while queue.pending():
+ queue.pop()
+ ",
+ ), @"refused: Extract variable `pending` (the expression can't be evaluated before its statement: it is evaluated again on every iteration of the loop)");
+ }
+
+ #[test]
+ fn conditional_branch_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(a, b):
+ return b.value if a else None
+ ",
+ ), @"refused: Extract variable `value` (the expression can't be evaluated before its statement: it is only evaluated when a condition holds)");
+ }
+
+ #[test]
+ fn lambda_body_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(items):
+ return sorted(items, key=lambda item: item.name)
+ ",
+ ), @"refused: Extract variable `name` (the expression can't be evaluated before its statement: it is evaluated later, when the enclosing function is called)");
+ }
+
+ #[test]
+ fn after_an_effect_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(a):
+ print(log(), a.value)
+
+ def log(): ...
+ ",
+ ), @"refused: Extract variable `value` (something with effects is evaluated before the expression, which would now run after it)");
+ }
+
+ #[test]
+ fn after_a_name_is_allowed() {
+ assert_snapshot!(extract(
+ "
+ def f(a, b):
+ print(a, b.value)
+ ",
+ ), @"
+ Extract variable `value`
+ ---
+
+ def f(a, b):
+ value = b.value
+ print(a, value)
+ ");
+ }
+
+ #[test]
+ fn elif_condition_is_refused() {
+ assert_snapshot!(extract(
+ "
+ def f(a):
+ if a.x:
+ pass
+ elif a.y:
+ pass
+ ",
+ ), @"refused: Extract variable `y` (the expression can't be evaluated before its statement: it is only evaluated when a condition holds)");
+ }
+
+ #[test]
+ fn class_body_is_refused() {
+ assert_snapshot!(extract(
+ "
+ class A:
+ x = compute()
+
+ def compute(): ...
+ ",
+ ), @"refused: Extract variable `compute_1` (a variable bound in a class body would become an attribute of the class)");
+ }
+
+ #[test]
+ fn partial_expression_is_not_offered() {
+ assert_snapshot!(extract(
+ "
+ def f(a, b, c):
+ return a + b + c
+ ",
+ ), @"not offered");
+ }
+
+ #[test]
+ fn assignment_target_is_not_offered() {
+ assert_snapshot!(extract(
+ "
+ def f(a):
+