Compile every imported module to a native extension first.
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.
Measure which lines run, with coverage.py, and write them to PATH as an LCOV report.
+
A transpiled module is reported against its .by file and lines: a .by line is covered when any statement written for it ran. A .py the project carries over is reported against the file in the project rather than the staged copy that ran. The project's coverage.py configuration applies to the project's files as they are in the project, with --in-build too, and coverage.py has to be installed in the interpreter the program runs on.
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.
The format to print the build's diagnostics in, as by check takes it.
+
They go to stderr whatever the format, among the lines saying what the build wrote, so a structured format is what lets a CI job or an editor read them out of the rest.
+
May also be set with the TY_OUTPUT_FORMAT environment variable.
Possible values:
+
+
full: Print diagnostics verbosely, with context and helpful hints (default)
+
concise: Print diagnostics concisely, one per line
+
gitlab: Print diagnostics in the JSON format expected by GitLab Code Quality reports
+
github: Print diagnostics in the format used by GitHub Actions workflow error annotations
+
junit: Print diagnostics as a JUnit-style XML report
Report 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.
which 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/src/args.rs b/crates/ty/src/args.rs
index b5de2bc9d5..6501251569 100644
--- a/crates/ty/src/args.rs
+++ b/crates/ty/src/args.rs
@@ -120,6 +120,18 @@ pub(crate) enum Command {
/// was started in.
#[arg(long)]
in_build: bool,
+ /// Measure which lines run, with coverage.py, and write them to PATH as
+ /// an LCOV report.
+ ///
+ /// A transpiled module is reported against its `.by` file and lines: a
+ /// `.by` line is covered when any statement written for it ran. A `.py`
+ /// the project carries over is reported against the file in the project
+ /// rather than the staged copy that ran. The project's coverage.py
+ /// configuration applies to the project's files as they are in the
+ /// project, with `--in-build` too, and coverage.py has to be installed in
+ /// the interpreter the program runs on.
+ #[arg(long, value_name = "PATH", conflicts_with = "compiled")]
+ coverage: Option,
},
/// Start a new project.
@@ -177,6 +189,13 @@ pub(crate) enum Command {
/// `package ` for every top-level package that came out.
#[arg(long)]
print_manifest: bool,
+ /// The format to print the build's diagnostics in, as `by check` takes it.
+ ///
+ /// They go to stderr whatever the format, among the lines saying what
+ /// the build wrote, so a structured format is what lets a CI job or an
+ /// editor read them out of the rest.
+ #[arg(long, env = EnvVars::TY_OUTPUT_FORMAT)]
+ output_format: Option,
#[command(flatten)]
lowering: LoweringArgs,
},
diff --git a/crates/ty/src/by_commands.rs b/crates/ty/src/by_commands.rs
index 43d732428a..6198450da1 100644
--- a/crates/ty/src/by_commands.rs
+++ b/crates/ty/src/by_commands.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
use std::ffi::OsStr;
use std::fs;
use std::io::{self, Read};
@@ -14,6 +14,7 @@ use ruff_db::diagnostic::{
use ruff_db::files::system_path_to_file;
use ruff_db::system::{OsSystem, SystemPath};
use ruff_text_size::TextRange;
+use ty_project::metadata::options::OutputFormat;
use ty_project::{Db, ProjectDatabase, ProjectMetadata};
use ty_site_packages::{PythonEnvironment, SysPrefixPathOrigin};
use ty_static::EnvVars;
@@ -127,9 +128,9 @@ impl LoweringArgs {
/// 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
+/// The choices a caller makes about the *process*: which interpreter, what
+/// stands in front of it, whether its modules are compiled first, which
+/// directory it runs in, and whether what it runs is measured. [`CompileFlags`] groups `by compile`'s for the same
/// reason.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct RunFlags<'a> {
@@ -141,6 +142,8 @@ pub(crate) struct RunFlags<'a> {
pub(crate) python: Option<&'a Path>,
/// `--launcher`: a program to start the interpreter through.
pub(crate) launcher: Option<&'a Path>,
+ /// `--coverage`: where to write an LCOV report of what ran.
+ pub(crate) coverage: Option<&'a Path>,
}
#[allow(clippy::exit, clippy::print_stderr)]
@@ -156,6 +159,7 @@ pub(crate) fn cmd_run(
in_build,
python: python_flag,
launcher,
+ coverage,
} = 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
@@ -241,6 +245,7 @@ pub(crate) fn cmd_run(
let status = render_check_and_transpile(
&db,
&handles,
+ OutputFormat::Full,
&mut Emit {
config: &config,
gate: CheckGate::AllErrors,
@@ -276,6 +281,17 @@ pub(crate) fn cmd_run(
&mut staging,
&BuildRecord::new(&root, &roots, Some(module.clone()), compiled, &config),
)?;
+ // the project file behind each copy in the tree, for the runner to run it as
+ // and a coverage report to name: the copy is deleted with the tree when the
+ // run ends
+ let carried: BTreeMap = staging
+ .entries()
+ .filter_map(|(staged, source)| {
+ let source = source.filter(|source| source.extension() != Some(OsStr::new("by")))?;
+ Some((staged.to_path_buf(), source.to_path_buf()))
+ })
+ .collect();
+ write_project_module(&mut staging, &root, &carried)?;
staging.finish()?;
if compiled {
@@ -365,8 +381,18 @@ pub(crate) fn cmd_run(
// 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() };
+ command.arg(tmp.path().join(BY_RUNNER_FILENAME));
+ // what the runner measured, written outside the tree the program can see
+ let measured = match coverage {
+ Some(_) => {
+ let dir = tempfile::TempDir::new().context("failed to create temp directory")?;
+ let file = dir.path().join("measured.json");
+ command.arg("--coverage").arg(&file);
+ Some((dir, file))
+ }
+ None => None,
+ };
let status = command
- .arg(tmp.path().join(BY_RUNNER_FILENAME))
.arg(&module)
.args(args)
.current_dir(workdir)
@@ -383,7 +409,28 @@ pub(crate) fn cmd_run(
),
})?;
- let code = status.code().unwrap_or(1);
+ let mut code = status.code().unwrap_or(1);
+ if let (Some(report), Some((_, file))) = (coverage, &measured) {
+ if file.is_file() {
+ let report = cwd.join(report);
+ if let Err(error) =
+ crate::by_coverage::write_report(file, &report, tmp.path(), &carried)
+ {
+ eprintln!("error: no coverage report was written: {error:#}");
+ if code == 0 {
+ code = 1;
+ }
+ }
+ } else if code != 2 {
+ // 2 is the runner refusing to start without coverage.py, which it has
+ // said already
+ eprintln!(
+ "note: no coverage report was written: the program ended without returning \
+ to `by run`, as `os._exit` does"
+ );
+ }
+ }
+ drop(measured);
// drop the temp dir explicitly: `process::exit` skips destructors, so
// exiting while it's still in scope would leak the directory
drop(tmp);
@@ -936,6 +983,7 @@ pub(crate) fn cmd_build(
lowering: &LoweringArgs,
out: Option<&Path>,
print_manifest: bool,
+ output_format: OutputFormat,
) -> anyhow::Result {
let cwd = std::env::current_dir().context("failed to get current directory")?;
let mut config = version_config(min_version, &cwd)?;
@@ -963,6 +1011,7 @@ pub(crate) fn cmd_build(
let status = render_check_and_transpile(
&db,
&handles,
+ output_format,
&mut Emit {
config: &config,
gate: CheckGate::ParseErrorsOnly,
@@ -1272,6 +1321,7 @@ pub(crate) fn cmd_compile(
let transpiled = render_check_and_transpile(
&db,
&transpilable,
+ OutputFormat::Full,
&mut Emit {
config: &tree_config,
gate: CheckGate::ParseErrorsOnly,
@@ -1724,6 +1774,7 @@ fn forward_dir(dir: &Path, config: &Config) -> anyhow::Result {
render_check_and_transpile(
&db,
&handles,
+ OutputFormat::Full,
&mut Emit {
config,
gate: CheckGate::ParseErrorsOnly,
@@ -1779,14 +1830,80 @@ fn write_traceback_runtime(
// through the staging, so that a project of its own with this name is
// reported as the collision it is rather than silently overwritten by a shim
// it knows nothing about
- staging.write(Path::new(BY_RUNNER_FILENAME), None, BY_RUNNER_SRC)
+ staging.write(Path::new(BY_RUNNER_FILENAME), None, BY_RUNNER_SRC)?;
+ staging.write(
+ Path::new(BY_COVERAGE_PLUGIN_FILENAME),
+ None,
+ BY_COVERAGE_PLUGIN_SRC,
+ )
+}
+
+/// the coverage.py plugin `by run --coverage` measures with, which the runner
+/// names by its module name
+const BY_COVERAGE_PLUGIN_FILENAME: &str = "_by_coverage_plugin.py";
+
+const BY_COVERAGE_PLUGIN_SRC: &str = r#"# generated by `by run` — the `.by` modules a run can leave out, for coverage.py
+import os
+
+import coverage
+
+from _by_sourcemap import SOURCEMAP
+
+
+class _ByModules(coverage.CoveragePlugin):
+ """The `.by` files transpiled into the tree, as files coverage.py looks for
+ under its `source` to report the ones that never ran, as it does a `.py`.
+
+ It traces nothing: a `.by` module that runs is compiled under its `.by` path
+ and lines, and measured like any python file."""
+
+ def file_tracer(self, filename):
+ return None
+
+ def find_executable_files(self, src_dir):
+ within = os.path.join(os.path.realpath(src_dir), "")
+ for by_path, _ in SOURCEMAP.values():
+ if os.path.realpath(by_path).startswith(within):
+ yield by_path
+
+
+def coverage_init(reg, options):
+ reg.add_file_tracer(_ByModules())
+"#;
+
+/// the runner's record of the project the tree was staged from
+const BY_PROJECT_FILENAME: &str = "_by_project.py";
+
+/// writes `_by_project.py`: the project's root, and the project file each
+/// hand-written module in the tree is a copy of, which the runner runs it as
+fn write_project_module(
+ staging: &mut Staging,
+ root: &Path,
+ carried: &BTreeMap,
+) -> anyhow::Result<()> {
+ // a json string is a python string literal, escapes included
+ let modules: BTreeMap = carried
+ .iter()
+ .filter(|(_, source)| source.extension() == Some(OsStr::new("py")))
+ .map(|(staged, source)| (staged.display().to_string(), source.display().to_string()))
+ .collect();
+ let text = format!(
+ "# generated by `by run` — the project this tree was staged from\n\
+ ROOT = {}\n\
+ CARRIED = {}\n",
+ serde_json::to_string(&root.display().to_string())?,
+ serde_json::to_string(&modules)?,
+ );
+ staging.write(Path::new(BY_PROJECT_FILENAME), None, &text)
}
const BY_RUNNER_SRC: &str = r#"# generated by `by run` — runs the target module with .by-aware tracebacks
+import atexit
import dis
import hashlib
import importlib.machinery
import importlib.util
+import json
import linecache
import os
import sys
@@ -1794,6 +1911,7 @@ import traceback
import types
import warnings
+from _by_project import CARRIED, ROOT
from _by_sourcemap import DIGESTS, SOURCEMAP
# index the sourcemap by realpath so symlinked temp dirs (e.g. /tmp on macOS)
@@ -1806,6 +1924,13 @@ _BY_SOURCES = {os.path.realpath(info[0]): py for py, info in SOURCEMAP.items()}
# this file, whose frames are never the program's
_RUNNER = os.path.realpath(__file__)
+# the tree the project was staged into, which this file is at the top of
+_TREE = os.path.dirname(_RUNNER)
+# the hand-written `.py` files carried over into the tree, each with the project
+# file it is a copy of
+_CARRIED = {
+ os.path.realpath(os.path.join(_TREE, staged)): source for staged, source in CARRIED.items()
+}
# entries already checked against their digests, so each file is read at most
# once no matter how many frames it contributes
@@ -1954,13 +2079,34 @@ def _excepthook(etype, evalue, tb):
sys.stderr.write("".join(traceback.format_exception_only(etype, evalue)))
-def _by_source_of(origin):
- # the .by file a staged .py was transpiled from, or None for anything else —
- # a hand-written .py carried over verbatim, or a module from site-packages
+def _project_file_of(origin):
+ # the project file a staged module stands for: the .by a staged .py was
+ # transpiled from, or the hand-written .py it is a copy of. None for anything
+ # else, such as a module from site-packages
if not origin:
return None
- entry = _BY_MAP.get(os.path.realpath(origin))
- return None if entry is None else entry[1][0]
+ real = os.path.realpath(origin)
+ entry = _BY_MAP.get(real)
+ return _CARRIED.get(real) if entry is None else entry[1][0]
+
+
+def _project_code(origin):
+ """The staged module at `origin` compiled as the project file it stands for, or
+ `None` when it stands for none, which leaves it to python's own loader.
+
+ A hand-written `.py` is a copy, so it is compiled from its text under the
+ project file's path: what python derives from the code object — a traceback,
+ `inspect`, what coverage.py measures — names the file the author wrote, as it
+ does for a `.by`."""
+ code = _by_code(origin)
+ if code is not None:
+ return code
+ project_file = _CARRIED.get(os.path.realpath(origin)) if origin else None
+ if project_file is None:
+ return None
+ with open(origin, "rb") as handle:
+ source = handle.read()
+ return compile(source, project_file, "exec", dont_inherit=True)
def _varint(out, value):
@@ -2123,19 +2269,27 @@ def _by_code(origin):
class _ByLoader:
- """Wraps a staged module's loader to name its `.by` source as `__file__`.
+ """Wraps a staged module's loader to name its project file as `__file__`.
The program is run out of a directory of transpiled copies, so every path
python derives from a module's origin — `__file__`, and anything a tool walks
up from it to find — names a temporary file in a directory that is deleted
- when the run ends. The `.by` the module was written as is the answer to every
- question those paths are asked, so it is what the module reports.
+ when the run ends. The file the module was written as, a `.by` or a
+ hand-written `.py`, is the answer to every question those paths are asked, so
+ it is what the module reports.
- A module python's own loader would compile is compiled by `_by_code` instead,
- under the `.by` path and lines. One any other loader stands behind — a test
- runner that rewrites assertions as it compiles — is left to it, and only its
+ This is the loader of a module some other loader stands behind — a test runner
+ that rewrites assertions as it compiles. It is left to that loader, and only its
`__file__` moves; its frames are rewritten to `.by` lines when they reach the
- excepthook.
+ excepthook. A module python's own loader would compile gets `_BySourceLoader`.
+
+ `__file__` is set as the module is created, and `exec_module` is not wrapped:
+ the inner loader's own is the one python calls. A wrapper's frame between
+ python's import machinery and the module's code is what kept python from
+ removing that machinery's frames from a traceback raised while importing — a
+ module that failed as it was imported showed ``
+ three times and this file once, above the line that failed. The cost is a
+ reload, which sets `__file__` back to the staged path and is not seen here.
"""
def __init__(self, inner, by_path):
@@ -2143,30 +2297,48 @@ class _ByLoader:
self._by_path = by_path
def create_module(self, spec):
- return self._by_inner.create_module(spec)
-
- def get_code(self, fullname):
- if type(self._by_inner) is importlib.machinery.SourceFileLoader:
- code = _by_code(self._by_inner.get_filename(fullname))
- if code is not None:
- return code
- return self._by_inner.get_code(fullname)
-
- def exec_module(self, module):
+ # python sets `__file__` on the module only when it has none
+ module = self._by_inner.create_module(spec)
+ if module is None:
+ module = types.ModuleType(spec.name)
module.__file__ = self._by_path
- if type(self._by_inner) is importlib.machinery.SourceFileLoader:
- code = _by_code(self._by_inner.get_filename(module.__name__))
- if code is not None:
- exec(code, module.__dict__)
- return
- self._by_inner.exec_module(module)
+ return module
def __getattr__(self, name):
return getattr(self._by_inner, name)
+class _BySourceLoader(importlib.machinery.SourceFileLoader):
+ """The loader of a staged module python's own loader would load.
+
+ It compiles the module with `_project_code`, under its project file's path —
+ and a `.by`'s lines — and names that file as `__file__`, like `_ByLoader`. It
+ is python's own source
+ loader rather than a wrapper around one, so that executing the module is
+ python's own `exec_module`, whose frames python removes from a traceback raised
+ while importing: what a failed import shows is the line that imported and the
+ line that failed, as it is for a program python runs itself.
+ """
+
+ def __init__(self, fullname, path, by_path):
+ super().__init__(fullname, path)
+ self._by_path = by_path
+
+ def get_code(self, fullname):
+ # python's `exec_module` asks for the code of the module it is about to
+ # run, which is in `sys.modules` by then — on an import and on a reload,
+ # which sets `__file__` back to the staged path just before
+ module = sys.modules.get(fullname)
+ if module is not None and getattr(module, "__loader__", None) is self:
+ module.__file__ = self._by_path
+ code = _project_code(self.get_filename(fullname))
+ if code is not None:
+ return code
+ return super().get_code(fullname)
+
+
class _ByFinder:
- """Puts a `_ByLoader` on every staged module, whoever ends up finding it."""
+ """Puts a loader of its own on every staged module, whoever ends up finding it."""
def find_spec(self, fullname, path=None, target=None):
for finder in sys.meta_path:
@@ -2178,8 +2350,10 @@ class _ByFinder:
spec = find(fullname, path, target)
if spec is None:
continue
- by_path = _by_source_of(getattr(spec, "origin", None))
- if by_path is not None and spec.loader is not None:
+ by_path = _project_file_of(getattr(spec, "origin", None))
+ if by_path is not None and type(spec.loader) is importlib.machinery.SourceFileLoader:
+ spec.loader = _BySourceLoader(spec.loader.name, spec.loader.path, by_path)
+ elif by_path is not None and spec.loader is not None:
spec.loader = _ByLoader(spec.loader, by_path)
return spec
return None
@@ -2225,7 +2399,7 @@ def _run_as_main(module):
code = spec.loader.get_code(module)
if code is None:
raise ImportError("module " + repr(module) + " has no code to run", name=module)
- by_path = _by_source_of(spec.origin) or spec.origin
+ by_path = _project_file_of(spec.origin) or spec.origin
sys.argv[0] = by_path
main_module = types.ModuleType("__main__")
main_module.__file__ = by_path
@@ -2242,11 +2416,150 @@ def _run_as_main(module):
exec(code, main_module.__dict__)
+def _start_coverage(report):
+ """Measure the program with coverage.py, and write what ran to `report` once the
+ program has ended: after its other threads and its own `atexit` handlers, which
+ python runs before the ones registered ahead of them."""
+ try:
+ import coverage
+ from coverage.exceptions import CoverageWarning
+ except ImportError:
+ sys.stderr.write(
+ "error: `by run --coverage` measures with coverage.py, which "
+ f"{sys.executable} cannot import\n"
+ " install it into that environment: `uv add --dev coverage`, or "
+ f"`{sys.executable} -m pip install coverage`\n"
+ )
+ sys.exit(2)
+ # the project's coverage configuration names the project's files, which is what
+ # every module runs as, so it is read, and its relative paths resolved, where
+ # the project is. a program run in the tree (`--in-build`) is in the project's
+ # directory that the tree's directory is a copy of
+ here = os.getcwd()
+ within = os.path.relpath(os.path.realpath(here), _TREE)
+ project_dir = here if within.split(os.sep)[0] == os.pardir else os.path.join(ROOT, within)
+ os.chdir(project_dir)
+ try:
+ # no data file: what was measured is read back below, and a `.coverage` left
+ # in the directory the program ran in would be a second, unmapped report of it
+ measurement = coverage.Coverage(data_file=None)
+ measurement.set_option(
+ "run:plugins", [*measurement.get_option("run:plugins"), "_by_coverage_plugin"]
+ )
+ with warnings.catch_warnings():
+ # a core that runs no plugin's tracer says so, and the plugin traces
+ # nothing: it only lists the `.by` files, which coverage.py asks it for
+ # whatever the core
+ warnings.filterwarnings(
+ "ignore",
+ message=r"Plugin file tracers \(_by_coverage_plugin\._ByModules\) aren't supported",
+ category=CoverageWarning,
+ )
+ measurement.start()
+ finally:
+ os.chdir(here)
+ atexit.register(_write_coverage, measurement, report)
+
+
+def _analysed(measurement, filename):
+ """The statements of `filename` and those of them that ran, as coverage.py counts
+ them, or `None` for a file coverage.py cannot read as python."""
+ try:
+ _, statements, _, missing, _ = measurement.analysis2(filename)
+ except Exception:
+ # a file python ran that has no python source to read: code compiled from a
+ # string under a made-up filename, which coverage.py's own report skips too
+ return None
+ return statements, sorted(set(statements) - set(missing))
+
+
+def _generated_analysis(measurement, filename, lines):
+ """The statements of the generated `.py` at `filename` and those of them `lines`
+ ran, as coverage.py's report reads them: each line that ran is put on the
+ statement it is part of. `None` when coverage.py cannot read the file."""
+ from coverage.exceptions import NoSource
+ from coverage.python import PythonFileReporter
+
+ try:
+ reporter = PythonFileReporter(filename, measurement)
+ statements = reporter.lines()
+ except NoSource:
+ return None
+ return statements, reporter.translate_lines(lines) & statements
+
+
+def _write_coverage(measurement, report):
+ """What ran, with every line of a transpiled module moved to its `.by` line.
+
+ coverage.py records a module by the filename and lines of its code objects. one
+ `_by_code` compiled is already the `.by` with its lines, and one another loader
+ compiled — a test runner rewriting assertions — is the staged `.py` with its own.
+ either way the statements are the generated python's, so each module's lines are
+ put back on its `.py`, analysed there, and only then moved through the sourcemap
+ to the `.by`. a `.by` line is covered when any statement written for it ran.
+
+ every other file is reported as coverage.py measured it, staged copies included:
+ `by run` puts those back where they came from once the run ends.
+ """
+ measurement.stop()
+ data = measurement.get_data()
+ ran = {}
+ files = {}
+ for measured in data.measured_files():
+ real = os.path.realpath(measured)
+ lines = data.lines(measured) or []
+ if real in _BY_SOURCES:
+ key = _BY_SOURCES[real]
+ by_lines = set(lines)
+ generated = SOURCEMAP[key][1]
+ ran.setdefault(key, set()).update(
+ index + 1
+ for index, line in enumerate(generated)
+ if line is not None and line + 1 in by_lines
+ )
+ elif real in _BY_MAP:
+ ran.setdefault(_BY_MAP[real][0], set()).update(lines)
+ else:
+ analysed = _analysed(measurement, measured)
+ if analysed is not None:
+ files[measured] = {"statements": analysed[0], "executed": analysed[1]}
+ for key, lines in ran.items():
+ if key in _STALE:
+ # its `.by` was saved over during the run, so no line of it is the line
+ # this ran as
+ continue
+ by_path, generated = SOURCEMAP[key]
+ analysed = _generated_analysis(measurement, key, lines)
+ if analysed is None:
+ continue
+ statements, executed = (
+ {
+ generated[number - 1] + 1
+ for number in numbers
+ if 0 < number <= len(generated) and generated[number - 1] is not None
+ }
+ for numbers in analysed
+ )
+ entry = files.setdefault(by_path, {"statements": [], "executed": []})
+ entry["statements"] = sorted(statements.union(entry["statements"]))
+ entry["executed"] = sorted(executed.union(entry["executed"]))
+ with open(report, "w", encoding="utf-8") as handle:
+ json.dump({"files": files}, handle)
+
+
def main():
sys.excepthook = _excepthook
sys.meta_path.insert(0, _ByFinder())
+ # `by run --coverage` names where to write what ran in front of the module,
+ # where no module name can be
+ report = None
+ if sys.argv[1] == "--coverage":
+ report = sys.argv[2]
+ del sys.argv[1:3]
module = sys.argv[1]
sys.argv = sys.argv[1:]
+ if report is not None:
+ _start_coverage(report)
try:
_run_as_main(module)
except SystemExit:
@@ -2286,12 +2599,13 @@ fn compilable_files(root: &Path) -> Vec {
fn render_check_and_transpile(
db: &ProjectDatabase,
handles: &[(PathBuf, ruff_db::files::File)],
+ format: OutputFormat,
emit: &mut Emit<'_>,
consume: impl FnMut(&Transpiled<'_>) -> anyhow::Result<()>,
) -> anyhow::Result {
let emitted = by_stage::emit::check_and_transpile(db, handles, emit, consume)?;
if !emitted.diagnostics.is_empty() {
- render_diagnostics(db, &emitted.diagnostics)?;
+ render_diagnostics_as(format, db, &emitted.diagnostics)?;
}
Ok(if emitted.ok {
ExitStatus::Success
@@ -2302,14 +2616,28 @@ fn render_check_and_transpile(
})
}
-/// Render diagnostics to stderr in the same format as `by check`. The
+/// [`render_diagnostics_as`] in the full format, which every command but
+/// `build` prints its diagnostics in.
+fn render_diagnostics(db: &ProjectDatabase, diagnostics: &[Diagnostic]) -> anyhow::Result<()> {
+ render_diagnostics_as(OutputFormat::Full, db, diagnostics)
+}
+
+/// Render diagnostics to stderr in `format`, as `by check` renders it. The
/// transpiled output goes to stdout, so diagnostics belong on stderr to keep
/// the two streams separable.
+///
+/// The count is a sentence for a person, so a format made for a program goes
+/// without it, as `by check`'s does.
#[allow(clippy::print_stderr)]
-fn render_diagnostics(db: &ProjectDatabase, diagnostics: &[Diagnostic]) -> anyhow::Result<()> {
+fn render_diagnostics_as(
+ format: OutputFormat,
+ db: &ProjectDatabase,
+ diagnostics: &[Diagnostic],
+) -> anyhow::Result<()> {
use std::io::Write as _;
let display_config = DisplayDiagnosticConfig::new("ty")
+ .format(format.into())
.color(colored::control::SHOULD_COLORIZE.should_colorize())
.context(0);
let mut stderr = std::io::stderr().lock();
@@ -2318,6 +2646,9 @@ fn render_diagnostics(db: &ProjectDatabase, diagnostics: &[Diagnostic]) -> anyho
"{}",
DisplayDiagnostics::new(db, &display_config, diagnostics)
)?;
+ if !format.is_human_readable() {
+ return Ok(());
+ }
let n = diagnostics.len();
writeln!(
stderr,
diff --git a/crates/ty/src/by_coverage.rs b/crates/ty/src/by_coverage.rs
new file mode 100644
index 0000000000..aa1b49383e
--- /dev/null
+++ b/crates/ty/src/by_coverage.rs
@@ -0,0 +1,200 @@
+//! `by run --coverage`: what a run executed, as an LCOV report against the files
+//! the project is written in.
+//!
+//! The measuring happens in the program's own process, where coverage.py runs:
+//! the runner `by run` stages measures the program, moves each transpiled
+//! module's lines to its `.by` through the sourcemap it already holds, and writes
+//! what it found as JSON (see `_write_coverage` in the runner). A `.py` the
+//! project carries over runs as its project file, but one a test runner compiled
+//! itself runs as the copy it compiled, in a temporary directory that is deleted
+//! when the run ends. The staging knows what each copy is a copy of, so the
+//! report is finished here: every staged copy is put back on the file it was
+//! copied from, and the runner's own files, which belong to no project, are left
+//! out.
+//!
+//! LCOV because it is the format the tools that read coverage share: an editor's
+//! gutter, `genhtml`, a CI service. Each line is reported as run once or not at
+//! all, since coverage.py records whether a line ran rather than how often.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt::Write as _;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use anyhow::Context;
+use serde::Deserialize;
+
+/// What the runner measured, keyed by the path it ran as.
+#[derive(Debug, Deserialize)]
+struct Measured {
+ files: BTreeMap,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Lines {
+ /// every line that holds the start of a statement
+ statements: Vec,
+ /// the statement lines that ran
+ executed: Vec,
+}
+
+/// One file's lines in the report.
+#[derive(Debug, Default, PartialEq, Eq)]
+struct Covered {
+ statements: BTreeSet,
+ executed: BTreeSet,
+}
+
+/// Turn the runner's measurement at `measured` into an LCOV report at `report`.
+///
+/// `tree` is the staged directory the program ran in, and `carried` maps a path
+/// within it to the project file it is a copy of.
+pub(crate) fn write_report(
+ measured: &Path,
+ report: &Path,
+ tree: &Path,
+ carried: &BTreeMap,
+) -> anyhow::Result<()> {
+ let text = fs::read_to_string(measured)
+ .with_context(|| format!("could not read {}", measured.display()))?;
+ let measured: Measured = serde_json::from_str(&text)
+ .with_context(|| format!("could not parse {}", measured.display()))?;
+ // python reports a file under its real path, and a temporary directory is
+ // reached through a symlink on macos, so the tree is recognised either way
+ let trees: Vec = std::iter::once(tree.to_path_buf())
+ .chain(fs::canonicalize(tree).ok())
+ .collect();
+ let covered = project_files(measured, &trees, carried);
+ if let Some(parent) = report
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("could not create {}", parent.display()))?;
+ }
+ fs::write(report, render_lcov(&covered))
+ .with_context(|| format!("could not write {}", report.display()))
+}
+
+/// The measurement with every staged copy moved back to its project file, and
+/// everything else in the staged tree dropped.
+fn project_files(
+ measured: Measured,
+ trees: &[PathBuf],
+ carried: &BTreeMap,
+) -> BTreeMap {
+ let mut covered: BTreeMap = BTreeMap::new();
+ for (path, lines) in measured.files {
+ let path = PathBuf::from(path);
+ let staged = trees.iter().find_map(|tree| path.strip_prefix(tree).ok());
+ let file = match staged {
+ // the runner, the sourcemap and the runtime helpers are staged from
+ // nothing, and a transpiled module's lines were moved to its `.by`
+ // already, so only a copy is put back
+ Some(relative) => match carried.get(relative) {
+ Some(source) => source.clone(),
+ None => continue,
+ },
+ None => path,
+ };
+ let entry = covered.entry(file).or_default();
+ entry.statements.extend(&lines.statements);
+ entry.statements.extend(&lines.executed);
+ entry.executed.extend(lines.executed);
+ }
+ covered
+}
+
+/// `covered` as LCOV tracefile records, one per file.
+fn render_lcov(covered: &BTreeMap) -> String {
+ let mut out = String::new();
+ for (file, lines) in covered {
+ let _ = writeln!(out, "TN:");
+ let _ = writeln!(out, "SF:{}", file.display());
+ for line in &lines.statements {
+ let hits = u32::from(lines.executed.contains(line));
+ let _ = writeln!(out, "DA:{line},{hits}");
+ }
+ let _ = writeln!(out, "LF:{}", lines.statements.len());
+ let _ = writeln!(out, "LH:{}", lines.executed.len());
+ let _ = writeln!(out, "end_of_record");
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn measured(json: &str) -> Measured {
+ serde_json::from_str(json).expect("valid measurement")
+ }
+
+ #[test]
+ fn a_file_outside_the_tree_is_reported_where_it_ran() {
+ let covered = project_files(
+ measured(r#"{"files": {"/p/main.by": {"statements": [1, 2, 4], "executed": [1, 4]}}}"#),
+ &[PathBuf::from("/tmp/run")],
+ &BTreeMap::new(),
+ );
+ assert_eq!(
+ render_lcov(&covered),
+ "TN:\nSF:/p/main.by\nDA:1,1\nDA:2,0\nDA:4,1\nLF:3\nLH:2\nend_of_record\n"
+ );
+ }
+
+ /// the copy ran out of a directory that is gone by the time anyone reads the
+ /// report, so it is reported against the file it was copied from
+ #[test]
+ fn a_staged_copy_is_reported_against_its_project_file() {
+ let carried = BTreeMap::from([(
+ PathBuf::from("pkg/helper.py"),
+ PathBuf::from("/p/pkg/helper.py"),
+ )]);
+ let covered = project_files(
+ measured(
+ r#"{"files": {"/private/tmp/run/pkg/helper.py": {"statements": [1], "executed": [1]}}}"#,
+ ),
+ &[PathBuf::from("/tmp/run"), PathBuf::from("/private/tmp/run")],
+ &carried,
+ );
+ assert_eq!(
+ covered.keys().collect::>(),
+ [Path::new("/p/pkg/helper.py")]
+ );
+ }
+
+ /// the runner and the sourcemap are `by run`'s, not the project's
+ #[test]
+ fn a_file_staged_from_nothing_is_left_out() {
+ let covered = project_files(
+ measured(
+ r#"{"files": {"/tmp/run/_by_runner.py": {"statements": [1], "executed": [1]}}}"#,
+ ),
+ &[PathBuf::from("/tmp/run")],
+ &BTreeMap::new(),
+ );
+ assert!(covered.is_empty(), "{covered:?}");
+ }
+
+ /// a project's `.py` can run both from where it was written and as the copy
+ /// staged beside the transpiled modules, and a line either of them ran is covered
+ #[test]
+ fn two_measurements_of_one_file_are_merged() {
+ let carried = BTreeMap::from([(PathBuf::from("helper.py"), PathBuf::from("/p/helper.py"))]);
+ let covered = project_files(
+ measured(
+ r#"{"files": {
+ "/p/helper.py": {"statements": [1, 2], "executed": [1]},
+ "/tmp/run/helper.py": {"statements": [1, 2], "executed": [2]}
+ }}"#,
+ ),
+ &[PathBuf::from("/tmp/run")],
+ &carried,
+ );
+ assert_eq!(
+ render_lcov(&covered),
+ "TN:\nSF:/p/helper.py\nDA:1,1\nDA:2,1\nLF:2\nLH:2\nend_of_record\n"
+ );
+ }
+}
diff --git a/crates/ty/src/cli-reference.md b/crates/ty/src/cli-reference.md
index b0db245e93..a8e0c3df6f 100644
--- a/crates/ty/src/cli-reference.md
+++ b/crates/ty/src/cli-reference.md
@@ -38,6 +38,23 @@ 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.
+### `--coverage`
+
+measure which lines the program runs and write them as an LCOV report:
+
+```sh
+by run --coverage coverage.lcov main
+by run --coverage coverage.lcov --in-build pytest
+```
+
+the report names the files you wrote: a transpiled module by its `.by` and the
+`.by` lines its statements came from, and a `.py` the build carries over by the
+file in the project rather than the staged copy that ran. measuring is
+coverage.py's, so it has to be installed in the interpreter the program runs on,
+and the project's coverage.py configuration applies: its `source`, `include` and
+`omit` name the project's files as they are in the project, with `--in-build`
+too
+
## `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 8a58a3b049..288916e382 100644
--- a/crates/ty/src/lib.rs
+++ b/crates/ty/src/lib.rs
@@ -1,5 +1,6 @@
mod args;
mod by_commands;
+mod by_coverage;
mod by_init;
mod by_lowering;
mod by_project_server;
@@ -124,6 +125,7 @@ fn run_command(command: Command) -> anyhow::Result {
lowering,
compiled,
in_build,
+ coverage,
} => by_commands::cmd_run(
module.as_deref(),
&args,
@@ -134,6 +136,7 @@ fn run_command(command: Command) -> anyhow::Result {
in_build,
python: python.as_deref(),
launcher: launcher.as_deref(),
+ coverage: coverage.as_deref(),
},
),
Command::Init {
@@ -161,6 +164,7 @@ fn run_command(command: Command) -> anyhow::Result {
wheels,
out,
print_manifest,
+ output_format,
lowering,
} => {
if wheels {
@@ -171,6 +175,7 @@ fn run_command(command: Command) -> anyhow::Result {
&lowering,
out.as_deref(),
print_manifest,
+ output_format.map(Into::into).unwrap_or_default(),
)
}
}
diff --git a/crates/ty/tests/by_e2e.rs b/crates/ty/tests/by_e2e.rs
index 7a24d1e7c4..923e65ba2b 100644
--- a/crates/ty/tests/by_e2e.rs
+++ b/crates/ty/tests/by_e2e.rs
@@ -1042,6 +1042,491 @@ fn run_in_build_runs_in_the_tree_it_staged() {
);
}
+/// The interpreter the coverage tests run on, and whether it can import
+/// coverage.py: `$PYTHON` when it is set, as for every python-executing test,
+/// and `python3` otherwise.
+fn coverage_interpreter() -> (String, bool) {
+ let python = std::env::var("PYTHON").unwrap_or_else(|_| "python3".to_owned());
+ let importable = Command::new(&python)
+ .args(["-c", "import coverage"])
+ .status()
+ .is_ok_and(|status| status.success());
+ (python, importable)
+}
+
+/// A project with a transpiled module that runs only partly, a hand-written
+/// `.py` the build carries over, and an entry point calling into both.
+fn project_with_partly_covered_modules(dir: &Path) {
+ let package = dir.join("pkg");
+ fs::create_dir_all(&package).unwrap();
+ fs::write(package.join("__init__.py"), "").unwrap();
+ fs::write(
+ package.join("calc.by"),
+ "def add(a: int, b: int) -> int:\n if a > 100:\n return -1\n return a + b\n",
+ )
+ .unwrap();
+ fs::write(
+ package.join("plain.py"),
+ "def helper() -> int:\n return 3\n\n\ndef never() -> int:\n return 4\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.join("main.by"),
+ "from pkg.calc import add\nfrom pkg.plain import helper\n\nprint(add(1, helper()))\n",
+ )
+ .unwrap();
+}
+
+/// The `DA:` lines of `file`'s record in an LCOV `report`, or `None` when it has
+/// no record.
+fn lcov_lines<'a>(report: &'a str, file: &Path) -> Option> {
+ let header = format!("SF:{}", file.display());
+ let record = report
+ .split("end_of_record")
+ .find(|record| record.lines().any(|line| line == header))?;
+ Some(
+ record
+ .lines()
+ .filter(|line| line.starts_with("DA:"))
+ .collect(),
+ )
+}
+
+/// coverage.py measures the generated python, and what reaches the report is
+/// the `.by` it was written from — the file and lines an editor shows — and the
+/// project's own `.py` rather than the staged copy of it that ran, which is gone
+/// once the run ends
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped leg must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_reports_by_lines_and_project_files() {
+ let (python, importable) = coverage_interpreter();
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_with_partly_covered_modules(dir.path());
+ let project = dir.path().canonicalize().unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args([
+ "run",
+ "--python",
+ &python,
+ "--coverage",
+ "out/report.lcov",
+ "main",
+ ])
+ .current_dir(dir.path())
+ .output()
+ .expect("failed to spawn by");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ if !importable {
+ eprintln!("only checking the refusal: {python} cannot import coverage");
+ assert!(
+ !output.status.success(),
+ "ran without coverage.py:\n{stderr}"
+ );
+ assert!(
+ stderr.contains("cannot import") && stderr.contains("pip install coverage"),
+ "the refusal says neither what is missing nor how to get it:\n{stderr}"
+ );
+ assert!(!dir.path().join("out/report.lcov").exists());
+ return;
+ }
+ assert!(
+ output.status.success(),
+ "by run --coverage failed:\n{stderr}"
+ );
+ assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "4");
+ let report = fs::read_to_string(dir.path().join("out/report.lcov")).expect("a report");
+
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/calc.by")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:3,0", "DA:4,1"]),
+ "calc.by is reported on its own lines:\n{report}"
+ );
+ assert_eq!(
+ lcov_lines(&report, &project.join("main.by")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:4,1"]),
+ "{report}"
+ );
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/plain.py")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:5,1", "DA:6,0"]),
+ "the staged copy is reported as the project file:\n{report}"
+ );
+ let named: Vec<&str> = report
+ .lines()
+ .filter_map(|line| line.strip_prefix("SF:"))
+ .collect();
+ assert!(
+ named
+ .iter()
+ .all(|file| Path::new(file).starts_with(&project)),
+ "a file outside the project — the runner, a staged copy — was reported: {named:?}"
+ );
+ assert!(
+ !dir.path().join(".coverage").exists(),
+ "coverage.py left a data file of its own beside the project"
+ );
+}
+
+/// The report of a `by run --coverage main` of `dir` with `python`, which can
+/// import coverage.py, after checking the run succeeded and printed `stdout`.
+fn coverage_report(dir: &Path, python: &str, stdout: &str) -> String {
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args([
+ "run",
+ "--python",
+ python,
+ "--coverage",
+ "report.lcov",
+ "main",
+ ])
+ .current_dir(dir)
+ .output()
+ .expect("failed to spawn by");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ output.status.success(),
+ "by run --coverage failed:\n{stderr}"
+ );
+ assert_eq!(String::from_utf8_lossy(&output.stdout), stdout, "{stderr}");
+ fs::read_to_string(dir.join("report.lcov"))
+ .unwrap_or_else(|error| panic!("no report ({error}):\n{stderr}"))
+}
+
+/// the project's coverage configuration names project files, and a hand-written
+/// `.py` runs as its project file, so `source` takes it in and `omit` leaves it out
+/// as it does a `.by`
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_applies_the_configuration_to_project_files() {
+ let (python, importable) = coverage_interpreter();
+ if !importable {
+ eprintln!("skipping: {python} cannot import coverage");
+ return;
+ }
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_with_partly_covered_modules(dir.path());
+ let project = dir.path().canonicalize().unwrap();
+
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[tool.coverage.run]\nsource = [\"pkg\"]\n",
+ )
+ .unwrap();
+ let report = coverage_report(dir.path(), &python, "4\n");
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/plain.py")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:5,1", "DA:6,0"]),
+ "{report}"
+ );
+ assert_eq!(
+ lcov_lines(&report, &project.join("main.by")),
+ None,
+ "main.by is outside `source`:\n{report}"
+ );
+
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[tool.coverage.run]\nomit = [\"pkg/plain.py\"]\n",
+ )
+ .unwrap();
+ let report = coverage_report(dir.path(), &python, "4\n");
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/plain.py")),
+ None,
+ "pkg/plain.py is omitted:\n{report}"
+ );
+ assert!(
+ lcov_lines(&report, &project.join("pkg/calc.by")).is_some(),
+ "{report}"
+ );
+}
+
+/// coverage.py reports a `.py` under `source` that never ran, and a `.by` that
+/// never ran is reported the same way, on every core coverage.py measures with
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_reports_a_by_module_that_never_ran() {
+ let (python, importable) = coverage_interpreter();
+ if !importable {
+ eprintln!("skipping: {python} cannot import coverage");
+ return;
+ }
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_with_partly_covered_modules(dir.path());
+ fs::write(
+ dir.path().join("pkg/unused.by"),
+ "def unused() -> int:\n return 5\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[tool.coverage.run]\nsource = [\"pkg\"]\n",
+ )
+ .unwrap();
+ let project = dir.path().canonicalize().unwrap();
+
+ for core in ["ctrace", "sysmon"] {
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .env("COVERAGE_CORE", core)
+ .args([
+ "run",
+ "--python",
+ &python,
+ "--coverage",
+ "report.lcov",
+ "main",
+ ])
+ .current_dir(dir.path())
+ .output()
+ .expect("failed to spawn by");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(output.status.success(), "{core}: by run failed:\n{stderr}");
+ assert!(
+ !stderr.contains("CoverageWarning"),
+ "{core}: coverage.py warned:\n{stderr}"
+ );
+ let report = fs::read_to_string(dir.path().join("report.lcov")).expect("a report");
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/unused.by")),
+ Some(vec!["DA:1,0", "DA:2,0"]),
+ "{core}: {report}"
+ );
+ }
+
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[tool.coverage.run]\nsource = [\"pkg\"]\nomit = [\"pkg/unused.by\"]\n",
+ )
+ .unwrap();
+ let report = coverage_report(dir.path(), &python, "4\n");
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/unused.by")),
+ None,
+ "pkg/unused.by is omitted:\n{report}"
+ );
+}
+
+/// `--in-build` runs the program in the staged tree, and the configuration still
+/// names the project's files: it is read where the project is
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_in_build_applies_the_configuration_to_project_files() {
+ let (python, importable) = coverage_interpreter();
+ if !importable {
+ eprintln!("skipping: {python} cannot import coverage");
+ return;
+ }
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_with_partly_covered_modules(dir.path());
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[tool.coverage.run]\nsource = [\"pkg\"]\n",
+ )
+ .unwrap();
+ let project = dir.path().canonicalize().unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["run", "--python", &python, "--in-build", "--coverage"])
+ .arg(project.join("report.lcov"))
+ .arg("main")
+ .current_dir(dir.path())
+ .output()
+ .expect("failed to spawn by");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ output.status.success(),
+ "by run --coverage failed:\n{stderr}"
+ );
+ let report = fs::read_to_string(project.join("report.lcov")).expect("a report");
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/calc.by")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:3,0", "DA:4,1"]),
+ "{report}"
+ );
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/plain.py")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:5,1", "DA:6,0"]),
+ "{report}"
+ );
+}
+
+/// a hand-written `.py` runs as its project file, so a traceback through it
+/// names that file rather than a copy in a directory gone once the run ends
+#[test]
+fn run_names_a_hand_written_module_s_project_file_in_a_traceback() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ fs::write(
+ dir.path().join("helper.py"),
+ "def fail() -> None:\n raise ValueError(\"no\")\n\nwhere = __file__\n",
+ )
+ .unwrap();
+ fs::write(
+ dir.path().join("main.by"),
+ "import helper\n\nprint(helper.where)\nhelper.fail()\n",
+ )
+ .unwrap();
+ let project = dir.path().canonicalize().unwrap();
+ let helper = project.join("helper.py");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["run", "main"])
+ .current_dir(dir.path())
+ .output()
+ .expect("failed to spawn by");
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert_eq!(
+ Path::new(stdout.trim()).canonicalize().ok(),
+ Some(helper.clone()),
+ "`__file__` names a staged copy:\n{stdout}\n{stderr}"
+ );
+ let raised_in = stderr
+ .lines()
+ .filter_map(|line| line.trim().strip_prefix("File \""))
+ .filter_map(|line| line.split('"').next())
+ .next_back()
+ .expect("a traceback");
+ assert_eq!(
+ Path::new(raised_in).canonicalize().ok(),
+ Some(helper),
+ "the traceback names a staged copy:\n{stderr}"
+ );
+}
+
+/// with branch coverage on, coverage.py records the arcs between lines rather
+/// than the lines, and the report still names the `.by` lines that ran
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_reports_lines_when_branches_are_measured() {
+ let (python, importable) = coverage_interpreter();
+ if !importable {
+ eprintln!("skipping: {python} cannot import coverage");
+ return;
+ }
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_with_partly_covered_modules(dir.path());
+ fs::write(
+ dir.path().join("pyproject.toml"),
+ "[tool.coverage.run]\nbranch = true\n",
+ )
+ .unwrap();
+ let project = dir.path().canonicalize().unwrap();
+
+ let report = coverage_report(dir.path(), &python, "4\n");
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/calc.by")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:3,0", "DA:4,1"]),
+ "{report}"
+ );
+}
+
+/// a program has not ended until its other threads have and its `atexit`
+/// handlers have run, and what they run is measured too
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_measures_threads_and_exit_handlers() {
+ let (python, importable) = coverage_interpreter();
+ if !importable {
+ eprintln!("skipping: {python} cannot import coverage");
+ return;
+ }
+ let dir = tempfile::tempdir().expect("tempdir");
+ fs::write(
+ dir.path().join("main.by"),
+ "import atexit\nimport threading\nimport time\n\ndef late():\n time.sleep(0.2)\n print(\"late\")\n\ndef bye():\n print(\"bye\")\n\natexit.register(bye)\nthreading.Thread(target=late).start()\n",
+ )
+ .unwrap();
+ let project = dir.path().canonicalize().unwrap();
+
+ let report = coverage_report(dir.path(), &python, "late\nbye\n");
+ let lines = lcov_lines(&report, &project.join("main.by")).expect("main.by is reported");
+ for ran in ["DA:6,1", "DA:7,1", "DA:10,1"] {
+ assert!(lines.contains(&ran), "{ran} is not covered:\n{report}");
+ }
+}
+
+/// a test runner compiles a test module itself, rewriting its assertions, so
+/// its code objects are the staged `.py`'s rather than the `.by`'s — and the
+/// report still names the `.by` and its lines
+#[test]
+#[expect(
+ clippy::print_stderr,
+ reason = "a skipped test must say why it skipped, or it reads as a pass"
+)]
+fn run_coverage_maps_a_module_a_test_runner_compiled() {
+ let (python, importable) = coverage_interpreter();
+ let pytest = Command::new(&python)
+ .args(["-c", "import pytest"])
+ .status()
+ .is_ok_and(|status| status.success());
+ if !importable || !pytest {
+ eprintln!("skipping: {python} cannot import both coverage and pytest");
+ return;
+ }
+ let dir = tempfile::tempdir().expect("tempdir");
+ project_with_partly_covered_modules(dir.path());
+ fs::create_dir_all(dir.path().join("tests")).unwrap();
+ fs::write(
+ dir.path().join("tests/test_calc.by"),
+ "from pkg.calc import add\n\n\ndef test_add():\n assert add(1, 2) == 3\n\n\ndef test_big():\n assert add(1000, 2) == -1\n",
+ )
+ .unwrap();
+ let project = dir.path().canonicalize().unwrap();
+ let report = project.join("report.lcov");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .arg("run")
+ .args(["--python", &python, "--in-build", "--coverage"])
+ .arg(&report)
+ .args(["pytest", "-q", "-p", "no:cacheprovider"])
+ .current_dir(dir.path())
+ .output()
+ .expect("failed to spawn by");
+ assert!(
+ output.status.success(),
+ "by run --coverage pytest failed:\n{}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let report = fs::read_to_string(&report).expect("a report");
+ assert_eq!(
+ lcov_lines(&report, &project.join("tests/test_calc.by")),
+ Some(vec!["DA:1,1", "DA:4,1", "DA:5,1", "DA:8,1", "DA:9,1"]),
+ "{report}"
+ );
+ assert_eq!(
+ lcov_lines(&report, &project.join("pkg/calc.by")),
+ Some(vec!["DA:1,1", "DA:2,1", "DA:3,1", "DA:4,1"]),
+ "{report}"
+ );
+}
+
/// `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
@@ -2529,6 +3014,66 @@ main()
);
}
+#[test]
+fn run_traceback_of_a_failed_import_shows_no_import_machinery() {
+ // python removes its own import machinery from a traceback raised while a
+ // module is imported, so that what shows is the line that imported and the
+ // line that failed. a module `by run` imports has to come out the same: the
+ // traceback is formatted by `traceback.print_exc`, which no excepthook sees
+ let dir = tempfile::tempdir().expect("tempdir");
+ fs::write(
+ dir.path().join("broken.by"),
+ "\
+table = {'a': 1}
+print(table['missing'])
+",
+ )
+ .unwrap();
+ fs::write(
+ dir.path().join("main.by"),
+ "\
+import traceback
+
+try:
+ import broken
+except KeyError:
+ traceback.print_exc()
+",
+ )
+ .unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["run", "main"])
+ .current_dir(dir.path())
+ .output()
+ .expect("failed to spawn by");
+
+ assert!(
+ output.status.success(),
+ "by run failed:\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let frames: Vec<&str> = stderr
+ .lines()
+ .filter(|line| line.trim_start().starts_with("File "))
+ .collect();
+ assert_eq!(
+ frames.len(),
+ 2,
+ "the importing line and the failing one:\n{stderr}"
+ );
+ assert!(
+ frames[0].contains("main.by") && frames[0].contains("line 4"),
+ "{stderr}"
+ );
+ assert!(
+ frames[1].contains("broken.by") && frames[1].contains("line 2"),
+ "{stderr}"
+ );
+}
+
#[test]
fn run_sourcemap_digests_match_the_files_they_describe() {
// `SOURCEMAP` describes a pair of files, and a consumer that reports a `.by`
@@ -2957,6 +3502,64 @@ fn build_renders_parse_error_and_aborts() {
);
}
+/// `--output-format` puts each diagnostic on a line of its own, apart from the lines saying what
+/// the build wrote, and drops the count, which is a sentence for a person
+#[test]
+fn build_prints_its_diagnostics_in_the_format_asked_for() {
+ let dir = Scratch::new("build_output_format");
+ fs::create_dir_all(&*dir).unwrap();
+ fs::write(dir.join("main.by"), "x: int = \"nope\"\n").unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .args(["build", "--output-format", "github"])
+ .current_dir(&*dir)
+ .output()
+ .expect("failed to spawn by");
+
+ assert!(
+ !output.status.success(),
+ "a diagnostic still fails the build"
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let diagnostics: Vec<&str> = stderr
+ .lines()
+ .filter(|line| line.starts_with("::"))
+ .collect();
+ assert_eq!(diagnostics.len(), 1, "one line per diagnostic:\n{stderr}");
+ assert!(
+ diagnostics[0].starts_with("::error title=ty (invalid-assignment),file=")
+ && diagnostics[0].contains("main.by,line=1,col=10,"),
+ "{stderr}"
+ );
+ assert!(stderr.contains("main.by -> "), "{stderr}");
+ assert!(!stderr.contains("Found 1 diagnostic"), "{stderr}");
+ // the output is written whatever the format
+ assert!(dir.join("build").join("main.py").exists());
+}
+
+/// the format is read from `TY_OUTPUT_FORMAT` too, as `by check` reads it
+#[test]
+fn build_reads_the_output_format_from_the_environment() {
+ let dir = Scratch::new("build_output_format_env");
+ fs::create_dir_all(&*dir).unwrap();
+ fs::write(dir.join("main.by"), "x: int = \"nope\"\n").unwrap();
+
+ let output = Command::new(env!("CARGO_BIN_EXE_by"))
+ .env(EnvVars::BY_NO_PROJECT_SERVER, "1")
+ .env(EnvVars::TY_OUTPUT_FORMAT, "concise")
+ .arg("build")
+ .current_dir(&*dir)
+ .output()
+ .expect("failed to spawn by");
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("main.by:1:10: error[invalid-assignment] "),
+ "{stderr}"
+ );
+}
+
#[test]
fn run_refuses_to_execute_on_check_errors() {
// a program that fails `by check` must never execute — here `T` cannot be
diff --git a/crates/ty_ide/Cargo.toml b/crates/ty_ide/Cargo.toml
index d09a291452..bbef7c325d 100644
--- a/crates/ty_ide/Cargo.toml
+++ b/crates/ty_ide/Cargo.toml
@@ -23,6 +23,7 @@ ruff_python_ast = { workspace = true }
ruff_python_codegen = { workspace = true }
ruff_python_importer = { workspace = true }
ruff_python_literal = { workspace = true }
+ruff_python_parser = { workspace = true }
ruff_python_stdlib = { workspace = true }
ruff_python_trivia = { workspace = true }
ruff_source_file = { workspace = true }
@@ -51,7 +52,6 @@ tracing = { workspace = true }
unicode-width = { workspace = true }
[dev-dependencies]
-ruff_python_parser = { workspace = true }
ruff_ranged_value = { workspace = true }
ty_project = { workspace = true, features = ["testing"] }
ty_static = { workspace = true }
diff --git a/crates/ty_ide/src/add_dependency.rs b/crates/ty_ide/src/add_dependency.rs
index f3a88ccb4d..7c8cc28fad 100644
--- a/crates/ty_ide/src/add_dependency.rs
+++ b/crates/ty_ide/src/add_dependency.rs
@@ -81,6 +81,7 @@ pub(crate) fn code_actions(db: &dyn Db, file: File, range: TextRange) -> Vec Vec,
+ /// The fix is the one import that resolves an unresolved name: no other module
+ /// has a member of that name, so nothing is left to choose. An editor may add an
+ /// import like this one as the user types, without asking, as IDEs do for
+ /// unambiguous imports.
+ pub unambiguous_import: bool,
}
/// An edit and the file it applies to.
@@ -52,6 +57,7 @@ impl QuickFix {
preferred,
create: None,
add_dependency: None,
+ unambiguous_import: false,
}
}
}
@@ -120,11 +126,19 @@ fn unresolved_fixes(
let parsed = parsed_module(db, file.python_file(db)).load(db);
let node = covering_node(parsed.syntax().into(), diagnostic_range).node();
let symbol = &node.expr_name()?.id;
- Some(
- completion::unresolved_fixes(db, file, &parsed, symbol, node)
- .into_iter()
- .map(move |import| QuickFix::new(import.label, file.file(db), vec![import.edit], true)),
- )
+ let fixes = completion::unresolved_fixes(db, file, &parsed, symbol, node);
+ // every qualification names a module one of the imports imports from, so a
+ // single import is a single thing the name can mean
+ let unambiguous = fixes.imports.len() == 1;
+ let imports = fixes.imports.into_iter().map(move |import| QuickFix {
+ unambiguous_import: unambiguous,
+ ..QuickFix::new(import.label, file.file(db), vec![import.edit], true)
+ });
+ let qualifications = fixes
+ .qualifications
+ .into_iter()
+ .map(move |import| QuickFix::new(import.label, file.file(db), vec![import.edit], true));
+ Some(imports.chain(qualifications))
}
#[cfg(test)]
@@ -807,6 +821,7 @@ mod tests {
2 | dt.timedelta(days=1)
| ^^
help: This is a preferred code action
+ help: This is the only import that resolves the name
|
1 + import datetime as dt
2 |
@@ -902,6 +917,7 @@ mod tests {
2 | ExecutionLoader
| ^^^^^^^^^^^^^^^
help: This is a preferred code action
+ help: This is the only import that resolves the name
|
1 + from importlib.abc import ExecutionLoader
2 |
@@ -940,6 +956,7 @@ mod tests {
3 | ExecutionLoader
| ^^^^^^^^^^^^^^^
help: This is a preferred code action
+ help: This is the only import that resolves the name
|
1 + from importlib.abc import ExecutionLoader
2 |
@@ -975,6 +992,7 @@ mod tests {
3 | ExecutionLoader
| ^^^^^^^^^^^^^^^
help: This is a preferred code action
+ help: This is the only import that resolves the name
|
1 + from importlib.abc import ExecutionLoader
2 |
@@ -1093,6 +1111,13 @@ mod tests {
));
}
+ if action.unambiguous_import {
+ diagnostic.sub(SubDiagnostic::new(
+ ruff_db::diagnostic::SubDiagnosticSeverity::Help,
+ "This is the only import that resolves the name",
+ ));
+ }
+
let mut edits = action.edits.into_iter().map(|edit| edit.edit);
if let Some(first) = edits.next() {
diagnostic.set_fix(Fix::safe_edits(first, edits));
diff --git a/crates/ty_ide/src/comma_list.rs b/crates/ty_ide/src/comma_list.rs
new file mode 100644
index 0000000000..9021a473c5
--- /dev/null
+++ b/crates/ty_ide/src/comma_list.rs
@@ -0,0 +1,299 @@
+//! Comma-separated lists — a call's arguments, a definition's parameters, the elements of a
+//! display, the names of an import — and the elements of each, as the parser read them.
+//!
+//! An element is read off the parse tree, not off the text between two commas: a lambda among the
+//! arguments has commas of its own (`f(lambda a, b: a, c)` has two arguments), and so does a nested
+//! call, and only the tree tells those apart from the list's. What the tree has no node for is
+//! found in the gaps between the nodes, where the only tokens are the list's own: the `/` and bare
+//! `*` that separate parameters, which are elements of their own, and the `*`, `**` that unpack an
+//! element or mark a variadic parameter, which belong to the element after them.
+
+use ruff_python_ast::token::{Token, TokenKind, Tokens, parenthesized_range};
+use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal};
+use ruff_python_ast::{self as ast, AnyNodeRef, Expr};
+use ruff_text_size::{Ranged, TextRange};
+
+/// What a list holds, which is what a client calls its elements.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ListKind {
+ /// A call's arguments, or a class's bases and keywords.
+ Arguments,
+ /// A `def`'s or a `lambda`'s parameters.
+ Parameters,
+ /// A type parameter list.
+ TypeParameters,
+ /// The elements of a list, tuple, set or dict display, or of a subscript's tuple.
+ Elements,
+ /// The names an `import`, `global` or `nonlocal` statement lists.
+ Names,
+ /// The targets of a `del`.
+ Targets,
+ /// The context managers of a `with`.
+ ContextManagers,
+}
+
+/// One comma-separated list.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct CommaList {
+ pub(crate) kind: ListKind,
+ /// The bracket tokens around the list, when it has brackets of its own: a call's parentheses,
+ /// a display's brackets, a parenthesised tuple's parentheses. `None` for a list that runs
+ /// without them, like a lambda's parameters or an import's names.
+ pub(crate) brackets: Option<(TextRange, TextRange)>,
+ /// The elements, in source order, each from its first token to its last: parentheses around
+ /// an element, and a `*` or `**` before one, included.
+ pub(crate) elements: Vec,
+}
+
+impl CommaList {
+ /// Between the brackets, both excluded; `None` for a list without brackets of its own.
+ pub(crate) fn inside(&self) -> Option {
+ let (open, close) = self.brackets?;
+ Some(TextRange::new(open.end(), close.start()))
+ }
+}
+
+/// The list `node` is, if it is one.
+///
+/// `None` for anything else, and for a list whose elements could not all be placed — which a
+/// parse that recovered from an error can leave behind.
+pub(crate) fn comma_list(node: AnyNodeRef, tokens: &Tokens) -> Option {
+ let expressions = |elements: &[Expr], parent: AnyNodeRef| -> Vec {
+ elements
+ .iter()
+ .map(|element| {
+ parenthesized_range(element.into(), parent, tokens).unwrap_or(element.range())
+ })
+ .collect()
+ };
+
+ let (kind, brackets, bounds, nodes) = match node {
+ AnyNodeRef::Arguments(arguments) => {
+ let nodes = arguments
+ .iter_source_order()
+ .map(|argument| match argument {
+ ast::ArgOrKeyword::Arg(expr) => {
+ parenthesized_range(expr.into(), node, tokens).unwrap_or(expr.range())
+ }
+ ast::ArgOrKeyword::Keyword(keyword) => keyword.range(),
+ })
+ .collect();
+ (
+ ListKind::Arguments,
+ bracketed(arguments.range(), tokens, TokenKind::Lpar, TokenKind::Rpar),
+ arguments.range(),
+ nodes,
+ )
+ }
+ AnyNodeRef::Parameters(parameters) => {
+ let nodes = parameters
+ .iter()
+ .map(|parameter| parameter.range())
+ .collect();
+ (
+ ListKind::Parameters,
+ bracketed(parameters.range(), tokens, TokenKind::Lpar, TokenKind::Rpar),
+ parameters.range(),
+ nodes,
+ )
+ }
+ AnyNodeRef::TypeParams(type_params) => (
+ ListKind::TypeParameters,
+ bracketed(
+ type_params.range(),
+ tokens,
+ TokenKind::Lsqb,
+ TokenKind::Rsqb,
+ ),
+ type_params.range(),
+ type_params.type_params.iter().map(Ranged::range).collect(),
+ ),
+ AnyNodeRef::ExprList(list) => (
+ ListKind::Elements,
+ bracketed(list.range(), tokens, TokenKind::Lsqb, TokenKind::Rsqb),
+ list.range(),
+ expressions(&list.elts, node),
+ ),
+ AnyNodeRef::ExprSet(set) => (
+ ListKind::Elements,
+ bracketed(set.range(), tokens, TokenKind::Lbrace, TokenKind::Rbrace),
+ set.range(),
+ expressions(&set.elts, node),
+ ),
+ AnyNodeRef::ExprTuple(tuple) => (
+ ListKind::Elements,
+ if tuple.parenthesized {
+ bracketed(tuple.range(), tokens, TokenKind::Lpar, TokenKind::Rpar)
+ } else {
+ None
+ },
+ tuple.range(),
+ expressions(&tuple.elts, node),
+ ),
+ AnyNodeRef::ExprDict(dict) => {
+ let nodes = dict
+ .items
+ .iter()
+ .map(|item| {
+ let value = parenthesized_range((&item.value).into(), node, tokens)
+ .unwrap_or(item.value.range());
+ match &item.key {
+ Some(key) => parenthesized_range(key.into(), node, tokens)
+ .unwrap_or(key.range())
+ .cover(value),
+ // the `**` before it is found in the gap
+ None => value,
+ }
+ })
+ .collect();
+ (
+ ListKind::Elements,
+ bracketed(dict.range(), tokens, TokenKind::Lbrace, TokenKind::Rbrace),
+ dict.range(),
+ nodes,
+ )
+ }
+ AnyNodeRef::StmtImport(import) => names(
+ ListKind::Names,
+ import.names.iter().map(Ranged::range).collect(),
+ )?,
+ AnyNodeRef::StmtImportFrom(import) => names(
+ ListKind::Names,
+ import.names.iter().map(Ranged::range).collect(),
+ )?,
+ AnyNodeRef::StmtGlobal(global) => names(
+ ListKind::Names,
+ global.names.iter().map(Ranged::range).collect(),
+ )?,
+ AnyNodeRef::StmtNonlocal(nonlocal) => names(
+ ListKind::Names,
+ nonlocal.names.iter().map(Ranged::range).collect(),
+ )?,
+ AnyNodeRef::StmtDelete(delete) => {
+ names(ListKind::Targets, expressions(&delete.targets, node))?
+ }
+ AnyNodeRef::StmtWith(with) => names(
+ ListKind::ContextManagers,
+ with.items.iter().map(Ranged::range).collect(),
+ )?,
+ _ => return None,
+ };
+
+ let bounds = match brackets {
+ Some((open, close)) => TextRange::new(open.end(), close.start()),
+ None => bounds,
+ };
+ let elements = with_gaps(bounds, &nodes, tokens)?;
+ Some(CommaList {
+ kind,
+ brackets,
+ elements,
+ })
+}
+
+/// A list of `nodes` with no brackets of its own, bounded by its first and last node.
+#[expect(clippy::type_complexity)]
+fn names(
+ kind: ListKind,
+ nodes: Vec,
+) -> Option<(
+ ListKind,
+ Option<(TextRange, TextRange)>,
+ TextRange,
+ Vec,
+)> {
+ let bounds = nodes.first()?.cover(*nodes.last()?);
+ Some((kind, None, bounds, nodes))
+}
+
+/// The `open` and `close` tokens at either end of `range`, when it starts and ends with them.
+fn bracketed(
+ range: TextRange,
+ tokens: &Tokens,
+ open: TokenKind,
+ close: TokenKind,
+) -> Option<(TextRange, TextRange)> {
+ let inner = tokens_within(tokens, range);
+ let first = inner.iter().find(|token| !token.kind().is_trivia())?;
+ let last = inner.iter().rev().find(|token| !token.kind().is_trivia())?;
+ (first.kind() == open
+ && last.kind() == close
+ && first.start() == range.start()
+ && last.end() == range.end())
+ .then(|| (first.range(), last.range()))
+}
+
+/// The elements of a list whose nodes are `nodes`, reading what lies between them inside
+/// `bounds`: a run of tokens between two commas is an element of its own (a `/` or bare `*`
+/// among parameters), and a run that ends at a node is part of it (the `**` of `**kwargs`).
+///
+/// `None` when the nodes are not in order inside the bounds, or a run between them is not one the
+/// list can hold, either of which is a parse recovered from an error.
+fn with_gaps(bounds: TextRange, nodes: &[TextRange], tokens: &Tokens) -> Option> {
+ let mut elements = Vec::with_capacity(nodes.len());
+ let mut position = bounds.start();
+ for index in 0..=nodes.len() {
+ let next = nodes.get(index).copied();
+ let gap_end = next.map_or(bounds.end(), TextRange::start);
+ if gap_end < position {
+ return None;
+ }
+ let mut run: Option = None;
+ for token in tokens_within(tokens, TextRange::new(position, gap_end)) {
+ match token.kind() {
+ kind if kind.is_trivia() => {}
+ TokenKind::Comma => {
+ elements.extend(run.take());
+ }
+ TokenKind::Star | TokenKind::DoubleStar | TokenKind::Slash => {
+ run = Some(run.map_or(token.range(), |run| run.cover(token.range())));
+ }
+ _ => return None,
+ }
+ }
+ match next {
+ Some(node) => {
+ if node.is_empty() {
+ return None;
+ }
+ elements.push(run.map_or(node, |run| run.cover(node)));
+ position = node.end();
+ }
+ None => elements.extend(run),
+ }
+ }
+ Some(elements)
+}
+
+/// The tokens wholly inside `range`.
+///
+/// Unlike [`Tokens::in_range`], this does not panic on a range whose ends fall inside a token,
+/// which the nodes of a parse recovered from an error can have: the outline that reads these is
+/// asked about every keystroke's text, the broken ones included.
+pub(crate) fn tokens_within(tokens: &Tokens, range: TextRange) -> &[Token] {
+ let start = tokens.partition_point(|token| token.start() < range.start());
+ let end = tokens.partition_point(|token| token.end() <= range.end());
+ tokens.get(start..end.max(start)).unwrap_or_default()
+}
+
+/// Every list in `suite` and everything nested in it, in the order their nodes start.
+pub(crate) fn comma_lists(suite: &[ast::Stmt], tokens: &Tokens) -> Vec {
+ struct Collector<'t> {
+ tokens: &'t Tokens,
+ lists: Vec,
+ }
+
+ impl<'a> SourceOrderVisitor<'a> for Collector<'_> {
+ fn enter_node(&mut self, node: AnyNodeRef<'a>) -> TraversalSignal {
+ self.lists.extend(comma_list(node, self.tokens));
+ TraversalSignal::Traverse
+ }
+ }
+
+ let mut collector = Collector {
+ tokens,
+ lists: Vec::new(),
+ };
+ collector.visit_body(suite);
+ collector.lists
+}
diff --git a/crates/ty_ide/src/complete_statement.rs b/crates/ty_ide/src/complete_statement.rs
new file mode 100644
index 0000000000..a973971fad
--- /dev/null
+++ b/crates/ty_ide/src/complete_statement.rs
@@ -0,0 +1,722 @@
+//! Completing the statement at the caret, and starting the line after it.
+//!
+//! What an editor's *Complete Current Statement* does: the statement being typed is finished off —
+//! a string literal closed, the brackets left open on the line closed, the `:` a clause header
+//! needs added, a clause that continues a compound statement moved out to it — and a new line is
+//! started where the next statement goes: inside the suite a header opens, or after the statement
+//! at its own indentation.
+//!
+//! ```python
+//! if handle(item, flags["x" # completed to:
+//!
+//! if handle(item, flags["x"]):
+//! |
+//! ```
+//!
+//! Which brackets are open, whether a header is missing its `:`, and where a statement ends are
+//! all read off the tokens and the parse, so that a `(` in a string or a comment is not a bracket,
+//! a `match = 1` is not a `match` statement, and a call spread over several lines is one statement.
+
+use ruff_db::PythonFile;
+use ruff_db::parsed::parsed_module;
+use ruff_db::source::source_text;
+use ruff_python_ast::token::{Token, TokenKind, Tokens};
+use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, walk_stmt};
+use ruff_python_ast::{Stmt, StringFlags};
+use ruff_python_codegen::Stylist;
+use ruff_python_trivia::leading_indentation;
+use ruff_source_file::LineRanges;
+use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
+
+use crate::Db;
+use crate::on_type_formatting::{continuing_clause_reindent, header_colon, indentation_width};
+use crate::syntax_outline::OutlineBuilder;
+
+/// The statement at the caret, completed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CompletedStatement {
+ /// What to change, in source order and none overlapping another, against the text as it is.
+ pub edits: Vec,
+ /// Where the caret goes, in the text the edits produce: the start of the new line, after its
+ /// indentation.
+ pub caret: TextSize,
+}
+
+/// One change: `range` replaced with `content`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StatementEdit {
+ pub range: TextRange,
+ pub content: String,
+}
+
+/// Completes the statement on the line `offset` is on, and starts the line after it.
+///
+/// `indent_unit` is one level of indentation as the file writes it, for the line a header's suite
+/// starts on when there is no statement in the suite yet to copy the indentation of.
+pub fn complete_statement(
+ db: &dyn Db,
+ file: PythonFile<'_>,
+ offset: TextSize,
+ indent_unit: &str,
+) -> CompletedStatement {
+ let parsed = parsed_module(db, file).load(db);
+ let tokens = parsed.tokens();
+ let source = source_text(db, file.file(db));
+ let source = source.as_str();
+ let line_ending = Stylist::from_tokens(tokens, source).line_ending();
+ let caret_line = source.line_range(offset);
+
+ let Some(statement) = Statement::at(tokens, caret_line) else {
+ // Nothing but whitespace or a comment on the line: a new line at its indentation.
+ let indentation = leading_indentation(&source[caret_line]);
+ return new_line(
+ source,
+ caret_line.end(),
+ "",
+ line_ending.as_str(),
+ indentation,
+ Vec::new(),
+ );
+ };
+
+ // The statement ends on the caret's line, unless what is open there — a bracket, a
+ // triple-quoted string, a backslash — is carried on to a later line that parses. A bracket the
+ // following lines do not close is not carried on: `foo(a, b` followed by `x = 1` leaves the
+ // parser reading `x` as a missing comma, and it is the `(` that is missing its `)`.
+ // The errors that count are the ones inside what is carried on: one where the logical line
+ // ends — the `:` a header is missing — is the statement's to have completed, not a sign that
+ // the lines below were never part of it.
+ let carried_on = statement.logical_end > caret_line.end()
+ && closes_what_is_open(tokens, statement.first, caret_line, statement.logical_end)
+ && !parsed.errors().iter().any(|error| {
+ error.location.start() >= caret_line.end()
+ && error.location.start() < statement.logical_end
+ });
+ let last_line = if carried_on {
+ source.line_range(statement.logical_end)
+ } else {
+ caret_line
+ };
+ let line = LineTokens::read(tokens, statement.first, last_line);
+
+ let mut fixes = String::new();
+ if !carried_on {
+ for opener in line.open.iter().rev() {
+ fixes.push_str(opener.closer());
+ }
+ }
+
+ let mut edits = Vec::new();
+ let statement_line = source.line_start(statement.start);
+ let header = Header::on_line(
+ db,
+ file,
+ tokens,
+ source,
+ TextRange::new(statement.start, line.code_end),
+ );
+ let indentation = match &header {
+ Some(header) => {
+ if header.needs_parentheses {
+ fixes.push_str("()");
+ }
+ if header.needs_colon {
+ fixes.push(':');
+ }
+ // A clause that continues a compound statement goes where that statement is.
+ let reindent = (source.line_start(header.anchor) == statement_line)
+ .then(|| continuing_clause_reindent(db, file, tokens, statement_line))
+ .flatten();
+ let header_indentation = match reindent {
+ Some(reindent) => {
+ edits.push(StatementEdit {
+ range: reindent.range,
+ content: reindent.indentation.clone(),
+ });
+ reindent.indentation
+ }
+ None => leading_indentation(&source[source.line_range(header.anchor)]).to_string(),
+ };
+ match header.suite {
+ // `if x: return` — the suite is done, and the next line is the statement's.
+ Suite::OnTheLine => header_indentation,
+ // A suite already written below is indented the way it is; a statement only
+ // counts as one if it is deeper than the header, rather than a recovery's reading
+ // of whatever came next.
+ Suite::Below(first)
+ if indentation_width(leading_indentation(
+ &source[source.line_range(first)],
+ )) > indentation_width(&header_indentation) =>
+ {
+ leading_indentation(&source[source.line_range(first)]).to_string()
+ }
+ Suite::Below(_) | Suite::Empty => header_indentation + indent_unit,
+ }
+ }
+ None => leading_indentation(&source[source.line_range(statement.start)]).to_string(),
+ };
+
+ // `foo(a # note` — the fixes go before the comment, and the new line after it.
+ let new_line_at = match line.comment {
+ Some(comment) => {
+ if !fixes.is_empty() {
+ edits.push(StatementEdit {
+ range: TextRange::empty(line.code_end),
+ content: std::mem::take(&mut fixes),
+ });
+ }
+ comment.end()
+ }
+ None => line.code_end,
+ };
+ new_line(
+ source,
+ new_line_at,
+ &fixes,
+ line_ending.as_str(),
+ &indentation,
+ edits,
+ )
+}
+
+/// Starts a line at `indentation` at `at`, after `prefix` and `line_ending`, dropping the
+/// whitespace that ends the line there; `edits` are the other changes, all before `at`.
+fn new_line(
+ source: &str,
+ at: TextSize,
+ prefix: &str,
+ line_ending: &str,
+ indentation: &str,
+ mut edits: Vec,
+) -> CompletedStatement {
+ // only whitespace follows `at` on its line: it is the end of the line's last token
+ let range = TextRange::new(at, source.line_end(at));
+ let content = format!("{prefix}{line_ending}{indentation}");
+ let mut caret = range.start() + content.text_len();
+ for edit in &edits {
+ caret = caret + edit.content.text_len() - edit.range.len();
+ }
+ edits.push(StatementEdit { range, content });
+ edits.sort_by_key(|edit| edit.range.start());
+ CompletedStatement { edits, caret }
+}
+
+/// Whether the brackets left open at the end of `line` are closed before `logical_end`. A logical
+/// line can end with brackets still open: at the end of the file, the parser ends it where the
+/// brackets ran out of text.
+fn closes_what_is_open(
+ tokens: &Tokens,
+ first: usize,
+ line: TextRange,
+ logical_end: TextSize,
+) -> bool {
+ let open = LineTokens::read(tokens, first, line)
+ .open
+ .iter()
+ .filter(|opener| matches!(opener, Opener::Bracket(_)))
+ .count();
+ let after = tokens.partition_point(|token| token.start() < line.end());
+ let depth = tokens[after..]
+ .iter()
+ .take_while(|token| token.start() < logical_end)
+ .fold(open, |depth, token| match token.kind() {
+ TokenKind::Lpar | TokenKind::Lsqb | TokenKind::Lbrace => depth + 1,
+ TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace => depth.saturating_sub(1),
+ _ => depth,
+ });
+ depth == 0
+}
+
+/// The logical line the caret's line belongs to.
+struct Statement {
+ /// The index of its first token.
+ first: usize,
+ /// Where its first token starts.
+ start: TextSize,
+ /// Where the logical line ends: the start of the `Newline` token that ends it.
+ logical_end: TextSize,
+}
+
+impl Statement {
+ /// The statement the line `line` is part of; `None` when there is nothing on it but whitespace
+ /// and comments.
+ fn at(tokens: &Tokens, line: TextRange) -> Option {
+ let before = tokens.partition_point(|token| token.end() <= line.start());
+ let after_newline = tokens[..before]
+ .iter()
+ .rposition(|token| token.kind() == TokenKind::Newline)
+ .map_or(0, |index| index + 1);
+ let first = after_newline
+ + tokens[after_newline..]
+ .iter()
+ .position(|token| !is_layout(token.kind()))?;
+ let start = tokens[first].start();
+ if start > line.end() {
+ return None;
+ }
+ let logical_end = tokens[first..]
+ .iter()
+ .find(|token| token.kind() == TokenKind::Newline)
+ .map_or_else(
+ || tokens.last().map_or(line.end(), Ranged::end),
+ Ranged::start,
+ );
+ Some(Self {
+ first,
+ start,
+ logical_end,
+ })
+ }
+}
+
+/// What a statement's tokens say up to the end of the line the statement is completed on.
+struct LineTokens {
+ /// The brackets and single-quoted strings still open at the end of that line, outermost
+ /// first.
+ open: Vec,
+ /// The end of the line's last token that is not a comment.
+ code_end: TextSize,
+ /// The comment ending that line.
+ comment: Option,
+}
+
+/// Something open that a statement being completed closes.
+#[derive(Debug, Clone, Copy)]
+enum Opener {
+ Bracket(TokenKind),
+ /// A string literal, with the quotes that close it; an f-string's or t-string's start, closed
+ /// by its end, can have brackets open inside it.
+ String(&'static str),
+}
+
+impl Opener {
+ fn closer(self) -> &'static str {
+ match self {
+ Opener::Bracket(TokenKind::Lpar) => ")",
+ Opener::Bracket(TokenKind::Lsqb) => "]",
+ Opener::Bracket(_) => "}",
+ Opener::String(quote) => quote,
+ }
+ }
+}
+
+impl LineTokens {
+ fn read(tokens: &Tokens, first: usize, line: TextRange) -> Self {
+ let mut open = Vec::new();
+ let mut code_end = tokens[first].end();
+ let mut comment = None;
+ for token in tokens[first..]
+ .iter()
+ .take_while(|token| token.start() < line.end())
+ {
+ match token.kind() {
+ TokenKind::Lpar | TokenKind::Lsqb | TokenKind::Lbrace => {
+ open.push(Opener::Bracket(token.kind()));
+ }
+ TokenKind::Rpar | TokenKind::Rsqb | TokenKind::Rbrace => {
+ if matches!(open.last(), Some(Opener::Bracket(_))) {
+ open.pop();
+ }
+ }
+ TokenKind::FStringStart | TokenKind::TStringStart => {
+ open.push(Opener::String(closing_quotes(token)));
+ }
+ TokenKind::FStringEnd | TokenKind::TStringEnd => {
+ // whatever was left open inside the interpolations ends with the string
+ if let Some(index) = open
+ .iter()
+ .rposition(|opener| matches!(opener, Opener::String(_)))
+ {
+ open.truncate(index);
+ }
+ }
+ TokenKind::String if token.start() >= line.start() => {
+ if token.string_flags().is_some_and(StringFlags::is_unclosed) {
+ open.push(Opener::String(closing_quotes(token)));
+ }
+ }
+ TokenKind::Comment => {
+ comment = Some(token.range());
+ continue;
+ }
+ kind if is_layout(kind) => continue,
+ _ => {}
+ }
+ code_end = token.end();
+ }
+ Self {
+ open,
+ code_end,
+ comment: comment.filter(|comment| comment.start() >= code_end),
+ }
+ }
+}
+
+/// The quotes that close the string `token` starts. A triple-quoted string left open runs on to
+/// the end of the file rather than to the end of the line, so where it was meant to end is not for
+/// the line to say; it is left open.
+fn closing_quotes(token: &Token) -> &'static str {
+ match token.string_flags() {
+ Some(flags) if !flags.is_triple_quoted() => flags.quote_str(),
+ _ => "",
+ }
+}
+
+/// Tokens that are part of no statement: line breaks, indentation and comments.
+fn is_layout(kind: TokenKind) -> bool {
+ matches!(
+ kind,
+ TokenKind::Newline
+ | TokenKind::NonLogicalNewline
+ | TokenKind::Indent
+ | TokenKind::Dedent
+ | TokenKind::Comment
+ )
+}
+
+/// A clause header on the logical line being completed.
+struct Header {
+ /// Where the header's keyword is, or its start for one with none (a trailing lambda's).
+ anchor: TextSize,
+ needs_colon: bool,
+ /// A `def` written without its parameter list.
+ needs_parentheses: bool,
+ suite: Suite,
+}
+
+/// Where a header's suite is.
+enum Suite {
+ /// There is none yet.
+ Empty,
+ /// On the header's own line: `if x: return`.
+ OnTheLine,
+ /// Starting at this offset, on a later line.
+ Below(TextSize),
+}
+
+impl Header {
+ /// The clause header the code of the logical line in `line` is, if it is one.
+ fn on_line(
+ db: &dyn Db,
+ file: PythonFile<'_>,
+ tokens: &Tokens,
+ source: &str,
+ line: TextRange,
+ ) -> Option {
+ let parsed = parsed_module(db, file).load(db);
+ let mut finder = HeaderFinder {
+ builder: OutlineBuilder::new(source, tokens),
+ tokens,
+ line,
+ found: None,
+ };
+ finder.visit_body(parsed.suite());
+ if let Some(header) = finder.found {
+ return Some(header);
+ }
+
+ // A clause keyword the parser made no statement of — an `else` with no `if` above it — is
+ // still a header being written.
+ let first = tokens.partition_point(|token| token.start() < line.start());
+ let keyword = tokens.get(first)?;
+ let is_compound_keyword = matches!(
+ keyword.kind(),
+ TokenKind::If
+ | TokenKind::Elif
+ | TokenKind::Else
+ | TokenKind::While
+ | TokenKind::For
+ | TokenKind::With
+ | TokenKind::Try
+ | TokenKind::Except
+ | TokenKind::Finally
+ | TokenKind::Def
+ | TokenKind::Class
+ | TokenKind::Async
+ // a soft keyword the parser kept as one
+ | TokenKind::Match
+ | TokenKind::Case
+ );
+ if !is_compound_keyword {
+ return None;
+ }
+ let has_colon =
+ header_colon(&tokens[first + 1..]).is_some_and(|colon| colon.start() < line.end());
+ Some(Self {
+ anchor: keyword.start(),
+ needs_colon: !has_colon,
+ needs_parentheses: false,
+ suite: Suite::Empty,
+ })
+ }
+}
+
+/// Finds the first clause header whose keyword is on the line being completed, statement
+/// expressions' included.
+struct HeaderFinder<'a> {
+ builder: OutlineBuilder<'a>,
+ tokens: &'a Tokens,
+ line: TextRange,
+ found: Option,
+}
+
+impl<'a> SourceOrderVisitor<'a> for HeaderFinder<'a> {
+ fn visit_stmt(&mut self, stmt: &'a Stmt) {
+ if self.found.is_some() || stmt.range().intersect(self.line).is_none() {
+ return;
+ }
+ for (index, head) in self.builder.clause_heads(stmt).iter().enumerate() {
+ // the `let` half of a `let ... := ... else:` opens no suite of its own
+ if index == 0 && matches!(stmt, Stmt::Let(_)) {
+ continue;
+ }
+ let anchor = head.keyword.map_or(head.range.start(), TextRange::start);
+ if !self.line.contains_inclusive(anchor) {
+ continue;
+ }
+ let needs_parentheses = match stmt {
+ Stmt::FunctionDef(function) if !function.is_trailing_lambda && index == 0 => {
+ let from = self
+ .tokens
+ .partition_point(|token| token.start() < function.name.end());
+ !self.tokens[from..]
+ .iter()
+ .take_while(|token| token.start() < self.line.end())
+ .any(|token| token.kind() == TokenKind::Lpar)
+ }
+ _ => false,
+ };
+ let suite = match head.body.first() {
+ None => Suite::Empty,
+ Some(first) if first.start() <= self.line.end() => Suite::OnTheLine,
+ Some(first) => Suite::Below(first.start()),
+ };
+ self.found = Some(Header {
+ anchor,
+ needs_colon: head.colon.is_none(),
+ needs_parentheses,
+ suite,
+ });
+ return;
+ }
+ walk_stmt(self, stmt);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ruff_source_file::LineEnding;
+
+ use super::complete_statement;
+ use crate::tests::CursorTest;
+
+ /// The source after completing the statement at ``, with `` where the caret
+ /// goes.
+ fn complete_in(path: &str, source: &str) -> String {
+ let test = CursorTest::builder().source(path, source).build();
+ let file = test.program_file(test.cursor.file).python_file(&test.db);
+ let completed = complete_statement(&test.db, file, test.cursor.offset, " ");
+ let mut text = test.cursor.source.as_str().to_string();
+ for edit in completed.edits.iter().rev() {
+ text.replace_range(edit.range.to_std_range(), &edit.content);
+ }
+ text.insert_str(completed.caret.to_usize(), "");
+ text
+ }
+
+ fn complete(source: &str) -> String {
+ complete_in("main.by", source)
+ }
+
+ #[test]
+ fn a_header_gets_its_colon_and_a_suite() {
+ assert_eq!(complete("if x\n"), "if x:\n \n");
+ assert_eq!(
+ complete("for item in items\n"),
+ "for item in items:\n \n"
+ );
+ assert_eq!(complete("while x\n"), "while x:\n \n");
+ assert_eq!(complete("class A\n"), "class A:\n \n");
+ assert_eq!(complete("try\n"), "try:\n \n");
+ assert_eq!(
+ complete("with open(p) as f\n"),
+ "with open(p) as f:\n \n"
+ );
+ }
+
+ #[test]
+ fn a_def_gets_its_parameters_too() {
+ assert_eq!(complete("def f\n"), "def f():\n \n");
+ assert_eq!(complete("def f(x\n"), "def f(x):\n \n");
+ assert_eq!(
+ complete("def f(x) -> int\n"),
+ "def f(x) -> int:\n \n"
+ );
+ }
+
+ #[test]
+ fn open_brackets_are_closed_innermost_first() {
+ assert_eq!(
+ complete("foo(a, [1, (2\n"),
+ "foo(a, [1, (2)])\n\n"
+ );
+ assert_eq!(
+ complete("if handle(item, flags[\"x\"\n"),
+ "if handle(item, flags[\"x\"]):\n \n"
+ );
+ }
+
+ #[test]
+ fn a_bracket_the_next_line_does_not_close_is_closed() {
+ assert_eq!(
+ complete("foo(a, b\nx = 1\n"),
+ "foo(a, b)\n\nx = 1\n"
+ );
+ }
+
+ #[test]
+ fn a_bracket_open_at_the_end_of_the_file_is_closed() {
+ assert_eq!(complete("foo(a,\n"), "foo(a,)\n\n");
+ }
+
+ #[test]
+ fn a_statement_carried_on_below_is_completed_where_it_ends() {
+ assert_eq!(
+ complete("foo(a,\n b)\nx = 1\n"),
+ "foo(a,\n b)\n\nx = 1\n"
+ );
+ assert_eq!(
+ complete("if (a and\n b)\n"),
+ "if (a and\n b):\n \n"
+ );
+ }
+
+ #[test]
+ fn a_string_left_open_is_closed() {
+ assert_eq!(
+ complete("print(\"hello\n"),
+ "print(\"hello\")\n\n"
+ );
+ assert_eq!(complete("x = 'a\n"), "x = 'a'\n\n");
+ assert_eq!(
+ complete("x = f\"a {y\n"),
+ "x = f\"a {y}\"\n\n"
+ );
+ }
+
+ #[test]
+ fn a_comment_stays_at_the_end() {
+ assert_eq!(
+ complete("foo(a # note\n"),
+ "foo(a) # note\n\n"
+ );
+ assert_eq!(
+ complete("if x # why\n"),
+ "if x: # why\n \n"
+ );
+ }
+
+ #[test]
+ fn a_complete_statement_gets_a_new_line_at_its_indentation() {
+ assert_eq!(
+ complete("def f():\n x = g(1)\n return x\n"),
+ "def f():\n x = g(1)\n \n return x\n"
+ );
+ }
+
+ #[test]
+ fn a_header_with_a_suite_below_starts_a_line_at_the_suites_indentation() {
+ assert_eq!(
+ complete("if x:\n pass\n"),
+ "if x:\n \n pass\n"
+ );
+ }
+
+ #[test]
+ fn a_one_line_suite_is_left_done() {
+ assert_eq!(
+ complete("def f():\n if x: return 1\n"),
+ "def f():\n if x: return 1\n \n"
+ );
+ }
+
+ #[test]
+ fn a_continuing_clause_is_moved_out_to_its_statement() {
+ assert_eq!(
+ complete("if x:\n a()\n else\n"),
+ "if x:\n a()\nelse:\n \n"
+ );
+ assert_eq!(
+ complete("try:\n a()\n except ValueError\n"),
+ "try:\n a()\nexcept ValueError:\n \n"
+ );
+ }
+
+ /// A clause with no statement above it to continue is read off its tokens, where a `:` in
+ /// brackets is not the one ending the header.
+ #[test]
+ fn a_colon_in_brackets_does_not_end_a_clause_the_parser_made_nothing_of() {
+ assert_eq!(
+ complete("if a:\n pass\n elif d[1:2]\n"),
+ "if a:\n pass\nelif d[1:2]:\n \n"
+ );
+ assert_eq!(
+ complete("elif {1: 2}\n"),
+ "elif {1: 2}:\n \n"
+ );
+ }
+
+ /// The line a statement is completed with ends the way the file's lines do.
+ #[test]
+ fn a_new_line_ends_like_the_file_s_lines() {
+ assert_eq!(complete("x = 1\r\n"), "x = 1\r\n\r\n");
+ assert_eq!(
+ complete("if x\r\ny = 2\r\n"),
+ "if x:\r\n \r\ny = 2\r\n"
+ );
+ }
+
+ #[test]
+ fn soft_keywords_are_the_parsers_call() {
+ assert_eq!(complete("match x\n"), "match x:\n \n");
+ assert_eq!(complete("match = 1\n"), "match = 1\n\n");
+ }
+
+ #[test]
+ fn an_if_statement_expression_is_a_header() {
+ assert_eq!(
+ complete("label = if n == 0\n"),
+ "label = if n == 0:\n \n"
+ );
+ }
+
+ #[test]
+ fn a_blank_line_gets_a_new_line() {
+ assert_eq!(
+ complete("def f():\n \n"),
+ "def f():\n \n \n"
+ );
+ }
+
+ #[test]
+ fn trailing_whitespace_does_not_stay_behind() {
+ assert_eq!(complete("x = 1 \n"), "x = 1\n\n");
+ }
+
+ /// A file with no line ending to copy gets the platform's, as every other edit does.
+ #[test]
+ fn the_last_line_of_a_file_without_a_newline() {
+ assert_eq!(
+ complete("foo(1"),
+ format!("foo(1){}", LineEnding::default().as_str())
+ );
+ }
+
+ #[test]
+ fn python_files_are_completed_too() {
+ assert_eq!(
+ complete_in("main.py", "elif_ = 1\nif x\n"),
+ "elif_ = 1\nif x:\n \n"
+ );
+ }
+}
diff --git a/crates/ty_ide/src/completion.rs b/crates/ty_ide/src/completion.rs
index 0db6380f12..ddfa0dd28f 100644
--- a/crates/ty_ide/src/completion.rs
+++ b/crates/ty_ide/src/completion.rs
@@ -2811,6 +2811,17 @@ pub(crate) struct ImportEdit {
pub edit: Edit,
}
+/// The fixes that would resolve an unresolved name, by how each one does it.
+pub(crate) struct UnresolvedFixes {
+ /// Imports that bind the name: one for each module with a member of that name, and the
+ /// import of a module under the common alias the name is, like `import numpy as np`.
+ pub imports: Vec,
+ /// Rewrites of the name into an attribute of a module the file imports already, like
+ /// `re.search` for `search` when the file has `import re`. Each names a module one of
+ /// [`Self::imports`] imports from too.
+ pub qualifications: Vec,
+}
+
/// Get fixes that would resolve an unresolved reference
pub(crate) fn unresolved_fixes<'db>(
db: &'db dyn Db,
@@ -2818,8 +2829,7 @@ pub(crate) fn unresolved_fixes<'db>(
parsed: &ParsedModuleRef,
symbol: &str,
node: AnyNodeRef,
-) -> Vec {
- let mut results = Vec::new();
+) -> UnresolvedFixes {
let query = UserQuery::exactly(symbol);
let ctx = CollectionContext::none();
@@ -2843,7 +2853,7 @@ pub(crate) fn unresolved_fixes<'db>(
},
&mut completions,
);
- results.extend(completions.into_imports());
+ let mut imports = completions.into_imports();
// Request qualifications we could apply to the symbol to make it resolve
let mut completions = Completions::new(db, file, ctx, query);
@@ -2858,13 +2868,16 @@ pub(crate) fn unresolved_fixes<'db>(
},
&mut completions,
);
- results.extend(completions.into_qualifications(node.range()));
+ let qualifications = completions.into_qualifications(node.range());
// an unresolved `np` is the name a common alias would have bound, so offer to write the
// import that binds it — the same one the completions above `np.` carry
- results.extend(alias_import(db, file, &importer, &members, symbol));
+ imports.extend(alias_import(db, file, &importer, &members, symbol));
- results
+ UnresolvedFixes {
+ imports,
+ qualifications,
+ }
}
/// The import that would bind `symbol`, when it is a common alias of a module the project has.
diff --git a/crates/ty_ide/src/django_template/diagnostics.rs b/crates/ty_ide/src/django_template/diagnostics.rs
index 97a5f60370..fe5a885edd 100644
--- a/crates/ty_ide/src/django_template/diagnostics.rs
+++ b/crates/ty_ide/src/django_template/diagnostics.rs
@@ -1044,6 +1044,7 @@ pub(crate) fn code_actions(
preferred: true,
create: Some(destination),
add_dependency: None,
+ unambiguous_import: false,
});
}
@@ -1059,6 +1060,7 @@ pub(crate) fn code_actions(
preferred: false,
create: None,
add_dependency: None,
+ unambiguous_import: false,
});
actions
diff --git a/crates/ty_ide/src/evaluatable_expression.rs b/crates/ty_ide/src/evaluatable_expression.rs
new file mode 100644
index 0000000000..b4fa497982
--- /dev/null
+++ b/crates/ty_ide/src/evaluatable_expression.rs
@@ -0,0 +1,271 @@
+//! the expression a debugger evaluates for a position: what a value tooltip
+//! shows on hover, and what *Evaluate Expression* and *Quick Evaluate* start
+//! from when nothing is selected
+//!
+//! it is the reference the name at the position is the end of. on `d` in
+//! `a.b[c].d` that is all of `a.b[c].d`, on `b` it is `a.b`, and on `c` it is
+//! `c`, the name inside the subscript. a client that guesses from the text
+//! cannot tell `d` there from a name of its own, and a guess that stops at the
+//! `]` answers nothing at all
+//!
+//! a debugger evaluates what this answers in the stopped program, so whether
+//! the expression may run code the program would not have run is part of the
+//! question. reading an attribute or an item is what a value tooltip does in
+//! every python debugger and is always answered. a call is answered only when
+//! the client says running one is fine, which is the difference between
+//! hovering a name and asking for it to be evaluated: on `f` in `f(x)` the
+//! answer is `f` when calls are not allowed and `f(x)` when they are, and a
+//! reference whose own value is a call's result, like `make().d`, is not
+//! answered at all when they are not
+//!
+//! the stopped program is python, so in a basedpython file only an expression
+//! that python reads the same way is answered: not `user?.name`
+
+use ruff_db::PythonFile;
+use ruff_db::parsed::parsed_module;
+use ruff_db::source::source_text;
+use ruff_python_ast::comparable::ComparableExpr;
+use ruff_python_ast::find_node::covering_node;
+use ruff_python_ast::helpers::any_over_expr;
+use ruff_python_ast::token::TokenKind;
+use ruff_python_ast::{AnyNodeRef, Expr, ExprContext, Mod};
+use ruff_python_parser::{Mode, ParseOptions, parse};
+use ruff_text_size::{Ranged, TextRange, TextSize};
+
+use crate::Db;
+
+/// the range of the expression a debugger evaluates for `offset`, or `None`
+/// when the position is not on a name an expression ends with
+///
+/// `calls` says whether the expression may call anything; see the module
+/// documentation
+pub fn evaluatable_expression(
+ db: &dyn Db,
+ file: PythonFile<'_>,
+ offset: TextSize,
+ calls: bool,
+) -> Option {
+ let parsed = parsed_module(db, file).load(db);
+ // the name is found from the tokens rather than from the tree, so that a
+ // position between `.` and a name, or right after a name, is on that name
+ let token = parsed
+ .tokens()
+ .at_offset(offset)
+ .find(|token| token.kind() == TokenKind::Name || token.kind().is_soft_keyword())?;
+ let covering = covering_node(parsed.syntax().into(), token.range());
+
+ let mut ancestors = covering.ancestors();
+ let mut node = ancestors.next()?;
+ if node.is_identifier() {
+ let parent = ancestors.next()?;
+ match parent {
+ // the attribute a reference ends with is the whole reference
+ AnyNodeRef::ExprAttribute(_) => node = parent,
+ // a parameter is bound in the frame the debugger is stopped in
+ AnyNodeRef::Parameter(_) => return Some(token.range()),
+ // the name a `def`, a class, an import or a keyword argument
+ // declares is not an expression, and evaluating the same text
+ // could mean something else in the stopped frame
+ _ => return None,
+ }
+ }
+
+ let expression = match node {
+ AnyNodeRef::ExprName(name) => {
+ // a name the lowering made up spans the whole construct it stands
+ // for, and has no text of its own to evaluate
+ if name.ctx == ExprContext::Invalid {
+ return None;
+ }
+ name.range()
+ }
+ AnyNodeRef::ExprAttribute(attribute) => {
+ if !calls && calls_anything(&attribute.value) {
+ return None;
+ }
+ attribute.range()
+ }
+ _ => return None,
+ };
+
+ let answer = if calls
+ && let Some(AnyNodeRef::ExprCall(call)) = ancestors.next()
+ && call.func.range() == expression
+ {
+ call.range()
+ } else {
+ expression
+ };
+
+ let source = source_text(db, file.file(db));
+ (!file.file(db).source_type(db).is_basedpython() || reads_the_same_in_python(&source[answer]))
+ .then_some(answer)
+}
+
+/// whether python reads `text`, an expression in a basedpython file, as the same expression:
+/// the program a debugger is stopped in is python, whatever its source was written in
+fn reads_the_same_in_python(text: &str) -> bool {
+ let read = |basedpython: bool| {
+ let options = ParseOptions::from(Mode::Expression).with_basedpython(basedpython);
+ match parse(text, options).ok()?.into_syntax() {
+ Mod::Expression(expression) => Some(expression.body),
+ Mod::Module(_) => None,
+ }
+ };
+ let (Some(python), Some(basedpython)) = (read(false), read(true)) else {
+ return false;
+ };
+ ComparableExpr::from(&python) == ComparableExpr::from(&basedpython)
+}
+
+/// whether evaluating `expression` would call something, or otherwise run code
+/// the program decides on, rather than read a value
+fn calls_anything(expression: &Expr) -> bool {
+ any_over_expr(expression, |expression| {
+ matches!(
+ expression,
+ Expr::Call(_)
+ | Expr::Await(_)
+ | Expr::Yield(_)
+ | Expr::YieldFrom(_)
+ | Expr::Named(_)
+ | Expr::Lambda(_)
+ | Expr::ListComp(_)
+ | Expr::SetComp(_)
+ | Expr::DictComp(_)
+ | Expr::Generator(_)
+ )
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::tests::CursorTest;
+
+ use super::evaluatable_expression;
+
+ /// the text of the answer for the `` in `source`, a `.by` file
+ fn evaluatable(source: &str, calls: bool) -> Option {
+ let test = CursorTest::builder().source("main.by", source).build();
+ let file = test.program_file(test.cursor.file).python_file(&test.db);
+ evaluatable_expression(&test.db, file, test.cursor.offset, calls)
+ .map(|range| test.cursor.source[range].to_string())
+ }
+
+ fn both(source: &str) -> (Option, Option) {
+ (evaluatable(source, false), evaluatable(source, true))
+ }
+
+ const CHAIN: &str = "a.b[c].d + compute(len(c))";
+
+ fn at(marker: &str) -> String {
+ let index = CHAIN.find(marker).expect("the marker is in the line");
+ format!("{}{}\n", &CHAIN[..index], &CHAIN[index..])
+ }
+
+ #[test]
+ fn the_last_name_of_a_reference_is_the_whole_reference() {
+ assert_eq!(evaluatable(&at("d +"), false).as_deref(), Some("a.b[c].d"));
+ }
+
+ #[test]
+ fn a_name_inside_a_reference_is_the_reference_up_to_it() {
+ assert_eq!(evaluatable(&at("a."), false).as_deref(), Some("a"));
+ assert_eq!(evaluatable(&at("b["), false).as_deref(), Some("a.b"));
+ }
+
+ #[test]
+ fn a_name_in_a_subscript_is_itself() {
+ assert_eq!(evaluatable(&at("c]"), false).as_deref(), Some("c"));
+ }
+
+ #[test]
+ fn a_callee_is_the_call_only_when_calls_are_allowed() {
+ assert_eq!(
+ both(&at("compute")),
+ (
+ Some("compute".to_string()),
+ Some("compute(len(c))".to_string())
+ )
+ );
+ assert_eq!(
+ both(&at("len")),
+ (Some("len".to_string()), Some("len(c)".to_string()))
+ );
+ }
+
+ #[test]
+ fn a_method_is_the_call_only_when_calls_are_allowed() {
+ assert_eq!(
+ both("items.append(1)\n"),
+ (
+ Some("items.append".to_string()),
+ Some("items.append(1)".to_string())
+ )
+ );
+ }
+
+ #[test]
+ fn a_reference_through_a_call_is_answered_only_when_calls_are_allowed() {
+ assert_eq!(
+ both("make().d\n"),
+ (None, Some("make().d".to_string()))
+ );
+ }
+
+ #[test]
+ fn a_position_just_after_a_name_or_just_before_it_is_on_it() {
+ assert_eq!(
+ evaluatable("total = 1\n", false).as_deref(),
+ Some("total")
+ );
+ assert_eq!(evaluatable("a.b\n", false).as_deref(), Some("a.b"));
+ }
+
+ #[test]
+ fn a_parameter_is_its_name() {
+ assert_eq!(
+ evaluatable("def f(limit: int):\n pass\n", false).as_deref(),
+ Some("limit")
+ );
+ }
+
+ #[test]
+ fn declared_names_are_not_expressions() {
+ assert_eq!(
+ evaluatable("def compute():\n pass\n", true),
+ None
+ );
+ assert_eq!(evaluatable("class Point:\n pass\n", true), None);
+ assert_eq!(evaluatable("import os\n", true), None);
+ assert_eq!(evaluatable("f(name=1)\n", true), None);
+ }
+
+ #[test]
+ fn literals_keywords_operators_and_comments_are_nothing() {
+ assert_eq!(evaluatable("x = \"text\"\n", true), None);
+ assert_eq!(evaluatable("x = None\n", true), None);
+ assert_eq!(evaluatable("x = 1 + 2\n", true), None);
+ assert_eq!(evaluatable("# a note\n", true), None);
+ }
+
+ /// the running program is python, so an expression it would read differently, or not at
+ /// all, is not answered
+ #[test]
+ fn basedpython_only_syntax_is_nothing() {
+ assert_eq!(evaluatable("user?.name\n", false), None);
+ assert_eq!(evaluatable("user?.name.first\n", false), None);
+ assert_eq!(
+ evaluatable("user?.name\n", false).as_deref(),
+ Some("user")
+ );
+ }
+
+ #[test]
+ fn a_name_in_an_f_string_is_itself() {
+ assert_eq!(
+ evaluatable("f\"{count} items\"\n", false).as_deref(),
+ Some("count")
+ );
+ }
+}
diff --git a/crates/ty_ide/src/find_references.rs b/crates/ty_ide/src/find_references.rs
index ef3ba082de..4eec244660 100644
--- a/crates/ty_ide/src/find_references.rs
+++ b/crates/ty_ide/src/find_references.rs
@@ -1,5 +1,5 @@
use crate::goto::find_goto_target;
-use crate::references::{FixtureReferenceTarget, ReferencesMode, references};
+use crate::references::{FixtureReferenceTarget, ReferencesMode, references, references_to_each};
use crate::{Db, ReferenceTarget};
use ruff_db::parsed::ParsedModuleRef;
use ruff_python_ast::AnyNodeRef;
@@ -38,6 +38,46 @@ pub fn find_references(
references(db, file, &goto_target, mode)
}
+/// What [`find_references`] answers at each of `offsets` in `file`, found with one pass over the
+/// project's files for all of them rather than one pass each (see [`references_to_each`]).
+pub(crate) fn find_references_to_each(
+ db: &dyn Db,
+ file: ProgramFile<'_>,
+ offsets: &[TextSize],
+ include_declaration: bool,
+) -> Vec