mirror of
https://github.com/astral-sh/ruff.git
synced 2025-08-09 21:28:21 +00:00
Rename Red Knot (#17820)
This commit is contained in:
parent
e6a798b962
commit
b51c4f82ea
1564 changed files with 1598 additions and 1578 deletions
40
crates/ty_test/Cargo.toml
Normal file
40
crates/ty_test/Cargo.toml
Normal file
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "ty_test"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
homepage.workspace = true
|
||||
documentation.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ruff_db = { workspace = true, features = ["os", "testing"] }
|
||||
ruff_index = { workspace = true }
|
||||
ruff_notebook = { workspace = true }
|
||||
ruff_python_trivia = { workspace = true }
|
||||
ruff_source_file = { workspace = true }
|
||||
ruff_text_size = { workspace = true }
|
||||
ruff_python_ast = { workspace = true }
|
||||
ty_python_semantic = { workspace = true, features = ["serde"] }
|
||||
ty_vendored = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
camino = { workspace = true }
|
||||
colored = { workspace = true }
|
||||
insta = { workspace = true, features = ["filters"] }
|
||||
memchr = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
salsa = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
542
crates/ty_test/README.md
Normal file
542
crates/ty_test/README.md
Normal file
|
@ -0,0 +1,542 @@
|
|||
# Writing type-checking / type-inference tests
|
||||
|
||||
Any Markdown file can be a test suite.
|
||||
|
||||
In order for it to be run as one, `ty_test::run` must be called with its path; see
|
||||
`crates/ty_python_semantic/tests/mdtest.rs` for an example that treats all Markdown files
|
||||
under a certain directory as test suites.
|
||||
|
||||
A Markdown test suite can contain any number of tests. A test consists of one or more embedded
|
||||
"files", each defined by a triple-backticks fenced code block. The code block must have a tag string
|
||||
specifying its language. We currently support `py` (Python files) and `pyi` (type stub files), as
|
||||
well as [typeshed `VERSIONS`] files and `toml` for configuration.
|
||||
|
||||
The simplest possible test suite consists of just a single test, with a single embedded file:
|
||||
|
||||
````markdown
|
||||
```py
|
||||
reveal_type(1) # revealed: Literal[1]
|
||||
```
|
||||
````
|
||||
|
||||
When running this test, the mdtest framework will write a file with these contents to the default
|
||||
file path (`/src/mdtest_snippet.py`) in its in-memory file system, run a type check on that file,
|
||||
and then match the resulting diagnostics with the assertions in the test. Assertions are in the form
|
||||
of Python comments. If all diagnostics and all assertions are matched, the test passes; otherwise,
|
||||
it fails.
|
||||
|
||||
<!---
|
||||
(If you are reading this document in raw Markdown source rather than rendered Markdown, note that
|
||||
the quadruple-backtick-fenced "markdown" language code block above is NOT itself part of the mdtest
|
||||
syntax, it's just how this README embeds an example mdtest Markdown document.)
|
||||
--->
|
||||
|
||||
See actual example mdtest suites in
|
||||
[`crates/ty_python_semantic/resources/mdtest`](https://github.com/astral-sh/ruff/tree/main/crates/ty_python_semantic/resources/mdtest).
|
||||
|
||||
> [!NOTE]
|
||||
> If you use `dir-test`, `rstest` or similar to generate a separate test for all Markdown files in a certain directory,
|
||||
> as with the example in `crates/ty_python_semantic/tests/mdtest.rs`,
|
||||
> you will likely want to also make sure that the crate the tests are in is rebuilt every time a
|
||||
> Markdown file is added or removed from the directory. See
|
||||
> [`crates/ty_python_semantic/build.rs`](https://github.com/astral-sh/ruff/tree/main/crates/ty_python_semantic/build.rs)
|
||||
> for an example of how to do this.
|
||||
>
|
||||
> This is because these macros generate their tests at build time rather than at runtime.
|
||||
> Without the `build.rs` file to force a rebuild when a Markdown file is added or removed,
|
||||
> a new Markdown test suite might not be run unless some other change in the crate caused a rebuild
|
||||
> following the addition of the new test file.
|
||||
|
||||
## Assertions
|
||||
|
||||
Two kinds of assertions are supported: `# revealed:` (shown above) and `# error:`.
|
||||
|
||||
### Assertion kinds
|
||||
|
||||
#### revealed
|
||||
|
||||
A `# revealed:` assertion should always be paired with a call to the `reveal_type` utility, which
|
||||
reveals (via a diagnostic) the inferred type of its argument (which can be any expression). The text
|
||||
after `# revealed:` must match exactly with the displayed form of the revealed type of that
|
||||
expression.
|
||||
|
||||
The `reveal_type` function can be imported from the `typing` standard library module (or, for older
|
||||
Python versions, from the `typing_extensions` pseudo-standard-library module[^extensions]):
|
||||
|
||||
```py
|
||||
from typing import reveal_type
|
||||
|
||||
reveal_type("foo") # revealed: Literal["foo"]
|
||||
```
|
||||
|
||||
For convenience, type checkers also pretend that `reveal_type` is a built-in, so that this import is
|
||||
not required. Using `reveal_type` without importing it issues a diagnostic warning that it was used
|
||||
without importing it, in addition to the diagnostic revealing the type of the expression.
|
||||
|
||||
The `# revealed:` assertion must always match a revealed-type diagnostic, and will also match the
|
||||
undefined-reveal diagnostic, if present, so it's safe to use `reveal_type` in tests either with or
|
||||
without importing it. (Style preference is to not import it in tests, unless specifically testing
|
||||
something about the behavior of importing it.)
|
||||
|
||||
#### error
|
||||
|
||||
A comment beginning with `# error:` is an assertion that a type checker diagnostic will be emitted,
|
||||
with text span starting on that line. The matching can be narrowed in three ways:
|
||||
|
||||
- `# error: [invalid-assignment]` requires that the matched diagnostic have the rule code
|
||||
`invalid-assignment`. (The square brackets are required.)
|
||||
- `# error: "Some text"` requires that the diagnostic's full message contain the text `Some text`.
|
||||
(The double quotes are required in the assertion comment; they are not part of the matched text.)
|
||||
- `# error: 8 [rule-code]` or `# error: 8 "Some text"` additionally requires that the matched
|
||||
diagnostic's text span begins on column 8 (one-indexed) of this line.
|
||||
|
||||
Assertions must contain either a rule code or a contains-text, or both, and may optionally also
|
||||
include a column number. They must come in order: first column, if present; then rule code, if
|
||||
present; then contains-text, if present. For example, an assertion using all three would look like
|
||||
`# error: 8 [invalid-assignment] "Some text"`.
|
||||
|
||||
Error assertions in tests intended to test type checker semantics should primarily use rule-code
|
||||
assertions, with occasional contains-text assertions where needed to disambiguate or validate some
|
||||
details of the diagnostic message.
|
||||
|
||||
### Assertion locations
|
||||
|
||||
An assertion comment may be a line-trailing comment, in which case it applies to the line it is on:
|
||||
|
||||
```py
|
||||
x: str = 1 # error: [invalid-assignment]
|
||||
```
|
||||
|
||||
Or it may be a comment on its own line, in which case it applies to the next line that does not
|
||||
contain an assertion comment:
|
||||
|
||||
```py
|
||||
# error: [invalid-assignment]
|
||||
x: str = 1
|
||||
```
|
||||
|
||||
Multiple assertions applying to the same line may be stacked:
|
||||
|
||||
```py
|
||||
# error: [invalid-assignment]
|
||||
# revealed: Literal[1]
|
||||
x: str = reveal_type(1)
|
||||
```
|
||||
|
||||
Intervening empty lines or non-assertion comments are not allowed; an assertion stack must be one
|
||||
assertion per line, immediately following each other, with the line immediately following the last
|
||||
assertion as the line of source code on which the matched diagnostics are emitted.
|
||||
|
||||
## Literate style
|
||||
|
||||
If multiple code blocks (without an explicit path, see below) are present in a single test, they will
|
||||
be merged into a single file in the order they appear in the Markdown file. This allows for tests that
|
||||
interleave code and explanations:
|
||||
|
||||
````markdown
|
||||
# My literate test
|
||||
|
||||
This first snippet here:
|
||||
|
||||
```py
|
||||
from typing import Literal
|
||||
|
||||
def f(x: Literal[1]):
|
||||
pass
|
||||
```
|
||||
|
||||
will be merged with this second snippet here, i.e. `f` is defined here:
|
||||
|
||||
```py
|
||||
f(2) # error: [invalid-argument-type]
|
||||
```
|
||||
````
|
||||
|
||||
## Diagnostic Snapshotting
|
||||
|
||||
In addition to inline assertions, one can also snapshot the full diagnostic
|
||||
output of a test. This is done by adding a `<!-- snapshot-diagnostics -->` directive
|
||||
in the corresponding section. For example:
|
||||
|
||||
````markdown
|
||||
## Unresolvable module import
|
||||
|
||||
<!-- snapshot-diagnostics -->
|
||||
|
||||
```py
|
||||
import zqzqzqzqzqzqzq # error: [unresolved-import] "Cannot resolve import `zqzqzqzqzqzqzq`"
|
||||
```
|
||||
````
|
||||
|
||||
The `snapshot-diagnostics` directive must appear before anything else in
|
||||
the section.
|
||||
|
||||
This will use `insta` to manage an external file snapshot of all diagnostic
|
||||
output generated.
|
||||
|
||||
Inline assertions, as described above, may be used in conjunction with diagnostic
|
||||
snapshotting.
|
||||
|
||||
At present, there is no way to do inline snapshotting or to request more granular
|
||||
snapshotting of specific diagnostics.
|
||||
|
||||
## Multi-file tests
|
||||
|
||||
Some tests require multiple files, with imports from one file into another. For this purpose,
|
||||
tests can specify explicit file paths in a separate line before the code block (`b.py` below):
|
||||
|
||||
````markdown
|
||||
```py
|
||||
from b import C
|
||||
reveal_type(C) # revealed: Literal[C]
|
||||
```
|
||||
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
class C: pass
|
||||
```
|
||||
````
|
||||
|
||||
Relative file names are always relative to the "workspace root", which is also an import root (that
|
||||
is, the equivalent of a runtime entry on `sys.path`).
|
||||
|
||||
The default workspace root is `/src/`. Currently it is not possible to customize this in a test, but
|
||||
this is a feature we will want to add in the future.
|
||||
|
||||
So the above test creates two files, `/src/mdtest_snippet.py` and `/src/b.py`, and sets the workspace
|
||||
root to `/src/`, allowing imports from `b.py` using the module name `b`.
|
||||
|
||||
## Multi-test suites
|
||||
|
||||
A single test suite (Markdown file) can contain multiple tests, by demarcating them using Markdown
|
||||
header lines:
|
||||
|
||||
````markdown
|
||||
# Same-file invalid assignment
|
||||
|
||||
```py
|
||||
x: int = "foo" # error: [invalid-assignment]
|
||||
```
|
||||
|
||||
# Cross-file invalid assignment
|
||||
|
||||
```py
|
||||
from b import y
|
||||
x: int = y # error: [invalid-assignment]
|
||||
```
|
||||
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
y = "foo"
|
||||
```
|
||||
````
|
||||
|
||||
This test suite contains two tests, one named "Same-file invalid assignment" and the other named
|
||||
"Cross-file invalid assignment". The first test involves only a single embedded file, and the second
|
||||
test involves two embedded files.
|
||||
|
||||
The tests are run independently, in independent in-memory file systems and with new ty
|
||||
[Salsa](https://github.com/salsa-rs/salsa) databases. This means that each is a from-scratch run of
|
||||
the type checker, with no data persisting from any previous test.
|
||||
|
||||
It is possible to filter to individual tests within a single markdown file using the
|
||||
`MDTEST_TEST_FILTER` environment variable. This variable will match any tests which contain the
|
||||
value as a case-sensitive substring in its name. An example test name is
|
||||
`unpacking.md - Unpacking - Tuple - Multiple assignment`, which contains the name of the markdown
|
||||
file and its parent headers joined together with hyphens.
|
||||
|
||||
## Structured test suites
|
||||
|
||||
Markdown headers can also be used to group related tests within a suite:
|
||||
|
||||
````markdown
|
||||
# Literals
|
||||
|
||||
## Numbers
|
||||
|
||||
### Integer
|
||||
|
||||
```py
|
||||
reveal_type(1) # revealed: Literal[1]
|
||||
```
|
||||
|
||||
### Float
|
||||
|
||||
```py
|
||||
reveal_type(1.0) # revealed: float
|
||||
```
|
||||
|
||||
## Strings
|
||||
|
||||
```py
|
||||
reveal_type("foo") # revealed: Literal["foo"]
|
||||
```
|
||||
````
|
||||
|
||||
This test suite contains three tests, named "Literals - Numbers - Integer", "Literals - Numbers -
|
||||
Float", and "Literals - Strings".
|
||||
|
||||
A header-demarcated section must either be a test or a grouping header; it cannot be both. That is,
|
||||
a header section can either contain embedded files (making it a test), or it can contain more
|
||||
deeply-nested headers (headers with more `#`), but it cannot contain both.
|
||||
|
||||
## Configuration
|
||||
|
||||
The test framework supports a TOML-based configuration format, which is a subset of the full ty
|
||||
configuration format. This configuration can be specified in fenced code blocks with `toml` as the
|
||||
language tag:
|
||||
|
||||
````markdown
|
||||
```toml
|
||||
[environment]
|
||||
python-version = "3.10"
|
||||
```
|
||||
````
|
||||
|
||||
This configuration will apply to all tests in the same section, and all nested sections within that
|
||||
section. Nested sections can override configurations from their parent sections.
|
||||
|
||||
See [`MarkdownTestConfig`](https://github.com/astral-sh/ruff/blob/main/crates/ty_test/src/config.rs) for the full list of supported configuration options.
|
||||
|
||||
### Specifying a custom typeshed
|
||||
|
||||
Some tests will need to override the default typeshed with custom files. The `[environment]`
|
||||
configuration option `typeshed` can be used to do this:
|
||||
|
||||
````markdown
|
||||
```toml
|
||||
[environment]
|
||||
typeshed = "/typeshed"
|
||||
```
|
||||
````
|
||||
|
||||
For more details, take a look at the [custom-typeshed Markdown test].
|
||||
|
||||
### Mocking a virtual environment
|
||||
|
||||
Mdtest supports mocking a virtual environment for a specific test at an arbitrary location, again
|
||||
using the `[environment]` configuration option:
|
||||
|
||||
````markdown
|
||||
```toml
|
||||
[environment]
|
||||
python = ".venv"
|
||||
```
|
||||
````
|
||||
|
||||
ty will reject virtual environments that do not have valid `pyvenv.cfg` files at the
|
||||
virtual-environment directory root (here, `.venv/pyvenv.cfg`). However, if a `pyvenv.cfg` file does
|
||||
not have its contents specified by the test, mdtest will automatically generate one for you, to
|
||||
make mocking a virtual environment more ergonomic.
|
||||
|
||||
Mdtest also makes it easy to write Python packages to the mock virtual environment's
|
||||
`site-packages` directory using the `<path-to-site-packages>` magic path segment. This would
|
||||
otherwise be hard, due to the fact that the `site-packages` subdirectory in a virtual environment
|
||||
is located at a different relative path depending on the platform the virtual environment was
|
||||
created on. In the following test, mdtest will write the Python file to
|
||||
`.venv/Lib/site-packages/foo.py` in its in-memory filesystem used for the test if the test is being
|
||||
executed on Windows, and `.venv/lib/python3.13/site-packages/foo.py` otherwise:
|
||||
|
||||
````markdown
|
||||
```toml
|
||||
[environment]
|
||||
python = ".venv"
|
||||
python-version = "3.13"
|
||||
```
|
||||
|
||||
`.venv/<path-to-site-packages>/foo.py`:
|
||||
|
||||
```py
|
||||
X = 1
|
||||
```
|
||||
````
|
||||
|
||||
## Documentation of tests
|
||||
|
||||
Arbitrary Markdown syntax (including of course normal prose paragraphs) is permitted (and ignored by
|
||||
the test framework) between fenced code blocks. This permits natural documentation of
|
||||
why a test exists, and what it intends to assert:
|
||||
|
||||
````markdown
|
||||
Assigning a string to a variable annotated as `int` is not permitted:
|
||||
|
||||
```py
|
||||
x: int = "foo" # error: [invalid-assignment]
|
||||
```
|
||||
````
|
||||
|
||||
## Running the tests
|
||||
|
||||
All Markdown-based tests are executed in a normal `cargo test` / `cargo run nextest` run. If you want to run the Markdown tests
|
||||
*only*, you can filter the tests using `mdtest__`:
|
||||
|
||||
```bash
|
||||
cargo test -p ty_python_semantic -- mdtest__
|
||||
```
|
||||
|
||||
Alternatively, you can use the `mdtest.py` runner which has a watch mode that will re-run corresponding tests when Markdown files change, and recompile automatically when Rust code changes:
|
||||
|
||||
```bash
|
||||
uv run crates/ty_python_semantic/mdtest.py
|
||||
```
|
||||
|
||||
## Planned features
|
||||
|
||||
There are some designed features that we intend for the test framework to have, but have not yet
|
||||
implemented:
|
||||
|
||||
### Multi-line diagnostic assertions
|
||||
|
||||
We may want to be able to assert that a diagnostic spans multiple lines, and to assert the columns it
|
||||
begins and/or ends on. The planned syntax for this will use `<<<` and `>>>` to mark the start and end lines for
|
||||
an assertion:
|
||||
|
||||
```py
|
||||
(3 # error: 2 [unsupported-operands] <<<
|
||||
+
|
||||
"foo") # error: 6 >>>
|
||||
```
|
||||
|
||||
The column assertion `6` on the ending line should be optional.
|
||||
|
||||
In cases of overlapping such assertions, resolve ambiguity using more angle brackets: `<<<<` begins
|
||||
an assertion ended by `>>>>`, etc.
|
||||
|
||||
### Configuring search paths and kinds
|
||||
|
||||
The ty TOML configuration format hasn't been finalized, and we may want to implement
|
||||
support in the test framework for configuring search paths before it is designed. If so, we can
|
||||
define some configuration options for now under the `[tests]` namespace. In the future, perhaps
|
||||
some of these can be replaced by real ty configuration options; some or all may also be
|
||||
kept long-term as test-specific options.
|
||||
|
||||
Some configuration options we will want to provide:
|
||||
|
||||
- We should be able to configure the default workspace root to something other than `/src/` using a
|
||||
`workspace-root` configuration option.
|
||||
|
||||
- We should be able to add a third-party root using the `third-party-root` configuration option.
|
||||
|
||||
- We may want to add additional configuration options for setting additional search path kinds.
|
||||
|
||||
Paths for `workspace-root` and `third-party-root` must be absolute.
|
||||
|
||||
Relative embedded-file paths are relative to the workspace root, even if it is explicitly set to a
|
||||
non-default value using the `workspace-root` config.
|
||||
|
||||
### I/O errors
|
||||
|
||||
We could use an `error=` configuration option in the tag string to make an embedded file cause an
|
||||
I/O error on read.
|
||||
|
||||
### Asserting on full diagnostic output
|
||||
|
||||
> [!NOTE]
|
||||
> At present, one can opt into diagnostic snapshotting that is managed via external files. See
|
||||
> the section above for more details. The feature outlined below, *inline* diagnostic snapshotting,
|
||||
> is still desirable.
|
||||
|
||||
The inline comment diagnostic assertions are useful for making quick, readable assertions about
|
||||
diagnostics in a particular location. But sometimes we will want to assert on the full diagnostic
|
||||
output of checking an embedded Python file. Or sometimes (see “incremental tests” below) we will
|
||||
want to assert on diagnostics in a file, without impacting the contents of that file by changing a
|
||||
comment in it. In these cases, a Python code block in a test could be followed by a fenced code
|
||||
block with language `output`; this would contain the full diagnostic output for the preceding test
|
||||
file:
|
||||
|
||||
````markdown
|
||||
# full output
|
||||
|
||||
```py
|
||||
x = 1
|
||||
reveal_type(x)
|
||||
```
|
||||
|
||||
This is just an example, not a proposal that ty would ever actually output diagnostics in
|
||||
precisely this format:
|
||||
|
||||
```output
|
||||
mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[1]'
|
||||
```
|
||||
````
|
||||
|
||||
We will want to build tooling to automatically capture and update these “full diagnostic output”
|
||||
blocks, when tests are run in an update-output mode (probably specified by an environment variable.)
|
||||
|
||||
By default, an `output` block will specify diagnostic output for the file
|
||||
`<workspace-root>/mdtest_snippet.py`. An `output` block can be prefixed by a
|
||||
<code>`<path>`:</code> label as usual, to explicitly specify the Python file for which it asserts
|
||||
diagnostic output.
|
||||
|
||||
It is an error for an `output` block to exist, if there is no `py` or `python` block in the same
|
||||
test for the same file path.
|
||||
|
||||
### Incremental tests
|
||||
|
||||
Some tests should validate incremental checking, by initially creating some files, checking them,
|
||||
and then modifying/adding/deleting files and checking again.
|
||||
|
||||
We should add the capability to create an incremental test by using the `stage=` option on some
|
||||
fenced code blocks in the test:
|
||||
|
||||
````markdown
|
||||
# Incremental
|
||||
|
||||
## modify a file
|
||||
|
||||
Initial file contents:
|
||||
|
||||
```py
|
||||
from b import x
|
||||
reveal_type(x)
|
||||
```
|
||||
|
||||
`b.py`:
|
||||
|
||||
```py
|
||||
x = 1
|
||||
```
|
||||
|
||||
Initial expected output for the unnamed file:
|
||||
|
||||
```output
|
||||
/src/mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[1]'
|
||||
```
|
||||
|
||||
Now in our first incremental stage, modify the contents of `b.py`:
|
||||
|
||||
`b.py`:
|
||||
|
||||
```py stage=1
|
||||
# b.py
|
||||
x = 2
|
||||
```
|
||||
|
||||
And this is our updated expected output for the unnamed file at stage 1:
|
||||
|
||||
```output stage=1
|
||||
/src/mdtest_snippet.py, line 1, col 1: revealed type is 'Literal[2]'
|
||||
```
|
||||
|
||||
(One reason to use full-diagnostic-output blocks in this test is that updating inline-comment
|
||||
diagnostic assertions for `mdtest_snippet.py` would require specifying new contents for
|
||||
`mdtest_snippet.py` in stage 1, which we don't want to do in this test.)
|
||||
````
|
||||
|
||||
It will be possible to provide any number of stages in an incremental test. If a stage re-specifies
|
||||
a filename that was specified in a previous stage (or the initial stage), that file is modified. A
|
||||
new filename appearing for the first time in a new stage will create a new file. To delete a
|
||||
previously created file, specify that file with the tag `delete` in its tag string (in this case, it
|
||||
is an error to provide non-empty contents). Any previously-created files that are not re-specified
|
||||
in a later stage continue to exist with their previously-specified contents, and are not "touched".
|
||||
|
||||
All stages should be run in order, incrementally, and then the final state should also be re-checked
|
||||
cold, to validate equivalence of cold and incremental check results.
|
||||
|
||||
[^extensions]: `typing-extensions` is a third-party module, but typeshed, and thus type checkers
|
||||
also, treat it as part of the standard library.
|
||||
|
||||
[custom-typeshed markdown test]: ../ty_python_semantic/resources/mdtest/mdtest_custom_typeshed.md
|
||||
[typeshed `versions`]: https://github.com/python/typeshed/blob/c546278aae47de0b2b664973da4edb613400f6ce/stdlib/VERSIONS#L1-L18%3E
|
817
crates/ty_test/src/assertion.rs
Normal file
817
crates/ty_test/src/assertion.rs
Normal file
|
@ -0,0 +1,817 @@
|
|||
//! Parse type and type-error assertions in Python comment form.
|
||||
//!
|
||||
//! Parses comments of the form `# revealed: SomeType` and `# error: 8 [rule-code] "message text"`.
|
||||
//! In the latter case, the `8` is a column number, and `"message text"` asserts that the full
|
||||
//! diagnostic message contains the text `"message text"`; all three are optional (`# error:` will
|
||||
//! match any error.)
|
||||
//!
|
||||
//! Assertion comments may be placed at end-of-line:
|
||||
//!
|
||||
//! ```py
|
||||
//! x: int = "foo" # error: [invalid-assignment]
|
||||
//! ```
|
||||
//!
|
||||
//! Or as a full-line comment on the preceding line:
|
||||
//!
|
||||
//! ```py
|
||||
//! # error: [invalid-assignment]
|
||||
//! x: int = "foo"
|
||||
//! ```
|
||||
//!
|
||||
//! Multiple assertion comments may apply to the same line; in this case all (or all but the last)
|
||||
//! must be full-line comments:
|
||||
//!
|
||||
//! ```py
|
||||
//! # error: [unbound-name]
|
||||
//! reveal_type(x) # revealed: Unbound
|
||||
//! ```
|
||||
//!
|
||||
//! or
|
||||
//!
|
||||
//! ```py
|
||||
//! # error: [unbound-name]
|
||||
//! # revealed: Unbound
|
||||
//! reveal_type(x)
|
||||
//! ```
|
||||
|
||||
use crate::db::Db;
|
||||
use ruff_db::files::File;
|
||||
use ruff_db::parsed::parsed_module;
|
||||
use ruff_db::source::{line_index, source_text, SourceText};
|
||||
use ruff_python_trivia::{CommentRanges, Cursor};
|
||||
use ruff_source_file::{LineIndex, OneIndexed};
|
||||
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
|
||||
use smallvec::SmallVec;
|
||||
use std::ops::Deref;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Diagnostic assertion comments in a single embedded file.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InlineFileAssertions {
|
||||
comment_ranges: CommentRanges,
|
||||
source: SourceText,
|
||||
lines: LineIndex,
|
||||
}
|
||||
|
||||
impl InlineFileAssertions {
|
||||
pub(crate) fn from_file(db: &Db, file: File) -> Self {
|
||||
let source = source_text(db, file);
|
||||
let lines = line_index(db, file);
|
||||
let parsed = parsed_module(db, file);
|
||||
let comment_ranges = CommentRanges::from(parsed.tokens());
|
||||
Self {
|
||||
comment_ranges,
|
||||
source,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
fn line_number(&self, range: &impl Ranged) -> OneIndexed {
|
||||
self.lines.line_index(range.start())
|
||||
}
|
||||
|
||||
fn is_own_line_comment(&self, ranged_assertion: &AssertionWithRange) -> bool {
|
||||
CommentRanges::is_own_line(ranged_assertion.start(), self.source.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a InlineFileAssertions {
|
||||
type Item = LineAssertions<'a>;
|
||||
type IntoIter = LineAssertionsIterator<'a>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
Self::IntoIter {
|
||||
file_assertions: self,
|
||||
inner: AssertionWithRangeIterator {
|
||||
file_assertions: self,
|
||||
inner: self.comment_ranges.into_iter(),
|
||||
}
|
||||
.peekable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`UnparsedAssertion`] with the [`TextRange`] of its original inline comment.
|
||||
#[derive(Debug)]
|
||||
struct AssertionWithRange<'a>(UnparsedAssertion<'a>, TextRange);
|
||||
|
||||
impl<'a> Deref for AssertionWithRange<'a> {
|
||||
type Target = UnparsedAssertion<'a>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Ranged for AssertionWithRange<'_> {
|
||||
fn range(&self) -> TextRange {
|
||||
self.1
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<AssertionWithRange<'a>> for UnparsedAssertion<'a> {
|
||||
fn from(value: AssertionWithRange<'a>) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator that yields all assertions within a single embedded Python file.
|
||||
#[derive(Debug)]
|
||||
struct AssertionWithRangeIterator<'a> {
|
||||
file_assertions: &'a InlineFileAssertions,
|
||||
inner: std::iter::Copied<std::slice::Iter<'a, TextRange>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for AssertionWithRangeIterator<'a> {
|
||||
type Item = AssertionWithRange<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let inner_next = self.inner.next()?;
|
||||
let comment = &self.file_assertions.source[inner_next];
|
||||
if let Some(assertion) = UnparsedAssertion::from_comment(comment) {
|
||||
return Some(AssertionWithRange(assertion, inner_next));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::iter::FusedIterator for AssertionWithRangeIterator<'_> {}
|
||||
|
||||
/// A vector of [`UnparsedAssertion`]s belonging to a single line.
|
||||
///
|
||||
/// Most lines will have zero or one assertion, so we use a [`SmallVec`] optimized for a single
|
||||
/// element to avoid most heap vector allocations.
|
||||
type AssertionVec<'a> = SmallVec<[UnparsedAssertion<'a>; 1]>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LineAssertionsIterator<'a> {
|
||||
file_assertions: &'a InlineFileAssertions,
|
||||
inner: std::iter::Peekable<AssertionWithRangeIterator<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for LineAssertionsIterator<'a> {
|
||||
type Item = LineAssertions<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let file = self.file_assertions;
|
||||
let ranged_assertion = self.inner.next()?;
|
||||
let mut collector = AssertionVec::new();
|
||||
let mut line_number = file.line_number(&ranged_assertion);
|
||||
// Collect all own-line comments on consecutive lines; these all apply to the same line of
|
||||
// code. For example:
|
||||
//
|
||||
// ```py
|
||||
// # error: [unbound-name]
|
||||
// # revealed: Unbound
|
||||
// reveal_type(x)
|
||||
// ```
|
||||
//
|
||||
if file.is_own_line_comment(&ranged_assertion) {
|
||||
collector.push(ranged_assertion.into());
|
||||
let mut only_own_line = true;
|
||||
while let Some(ranged_assertion) = self.inner.peek() {
|
||||
let next_line_number = line_number.saturating_add(1);
|
||||
if file.line_number(ranged_assertion) == next_line_number {
|
||||
if !file.is_own_line_comment(ranged_assertion) {
|
||||
only_own_line = false;
|
||||
}
|
||||
line_number = next_line_number;
|
||||
collector.push(self.inner.next().unwrap().into());
|
||||
// If we see an end-of-line comment, it has to be the end of the stack,
|
||||
// otherwise we'd botch this case, attributing all three errors to the `bar`
|
||||
// line:
|
||||
//
|
||||
// ```py
|
||||
// # error:
|
||||
// foo # error:
|
||||
// bar # error:
|
||||
// ```
|
||||
//
|
||||
if !only_own_line {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if only_own_line {
|
||||
// The collected comments apply to the _next_ line in the code.
|
||||
line_number = line_number.saturating_add(1);
|
||||
}
|
||||
} else {
|
||||
// We have a line-trailing comment; it applies to its own line, and is not grouped.
|
||||
collector.push(ranged_assertion.into());
|
||||
}
|
||||
Some(LineAssertions {
|
||||
line_number,
|
||||
assertions: collector,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::iter::FusedIterator for LineAssertionsIterator<'_> {}
|
||||
|
||||
/// One or more assertions referring to the same line of code.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LineAssertions<'a> {
|
||||
/// The line these assertions refer to.
|
||||
///
|
||||
/// Not necessarily the same line the assertion comment is located on; for an own-line comment,
|
||||
/// it's the next non-assertion line.
|
||||
pub(crate) line_number: OneIndexed,
|
||||
|
||||
/// The assertions referring to this line.
|
||||
pub(crate) assertions: AssertionVec<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Deref for LineAssertions<'a> {
|
||||
type Target = [UnparsedAssertion<'a>];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.assertions
|
||||
}
|
||||
}
|
||||
|
||||
/// A single diagnostic assertion comment.
|
||||
///
|
||||
/// This type represents an *attempted* assertion, but not necessarily a *valid* assertion.
|
||||
/// Parsing is done lazily in `matcher.rs`; this allows us to emit nicer error messages
|
||||
/// in the event of an invalid assertion
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum UnparsedAssertion<'a> {
|
||||
/// A `# revealed:` assertion.
|
||||
Revealed(&'a str),
|
||||
|
||||
/// An `# error:` assertion.
|
||||
Error(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> UnparsedAssertion<'a> {
|
||||
/// Returns `Some(_)` if the comment starts with `# error:` or `# revealed:`,
|
||||
/// indicating that it is an assertion comment.
|
||||
fn from_comment(comment: &'a str) -> Option<Self> {
|
||||
let comment = comment.trim().strip_prefix('#')?.trim();
|
||||
let (keyword, body) = comment.split_once(':')?;
|
||||
let keyword = keyword.trim();
|
||||
let body = body.trim();
|
||||
|
||||
match keyword {
|
||||
"revealed" => Some(Self::Revealed(body)),
|
||||
"error" => Some(Self::Error(body)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the attempted assertion into a [`ParsedAssertion`] structured representation.
|
||||
pub(crate) fn parse(&self) -> Result<ParsedAssertion<'a>, PragmaParseError<'a>> {
|
||||
match self {
|
||||
Self::Revealed(revealed) => {
|
||||
if revealed.is_empty() {
|
||||
Err(PragmaParseError::EmptyRevealTypeAssertion)
|
||||
} else {
|
||||
Ok(ParsedAssertion::Revealed(revealed))
|
||||
}
|
||||
}
|
||||
Self::Error(error) => ErrorAssertion::from_str(error)
|
||||
.map(ParsedAssertion::Error)
|
||||
.map_err(PragmaParseError::ErrorAssertionParseError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UnparsedAssertion<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Revealed(expected_type) => write!(f, "revealed: {expected_type}"),
|
||||
Self::Error(assertion) => write!(f, "error: {assertion}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An assertion comment that has been parsed and validated for correctness.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ParsedAssertion<'a> {
|
||||
/// A `# revealed:` assertion.
|
||||
Revealed(&'a str),
|
||||
|
||||
/// An `# error:` assertion.
|
||||
Error(ErrorAssertion<'a>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ParsedAssertion<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Revealed(expected_type) => write!(f, "revealed: {expected_type}"),
|
||||
Self::Error(assertion) => assertion.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed and validated `# error:` assertion comment.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ErrorAssertion<'a> {
|
||||
/// The diagnostic rule code we expect.
|
||||
pub(crate) rule: Option<&'a str>,
|
||||
|
||||
/// The column we expect the diagnostic range to start at.
|
||||
pub(crate) column: Option<OneIndexed>,
|
||||
|
||||
/// A string we expect to be contained in the diagnostic message.
|
||||
pub(crate) message_contains: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ErrorAssertion<'a> {
|
||||
fn from_str(source: &'a str) -> Result<Self, ErrorAssertionParseError<'a>> {
|
||||
ErrorAssertionParser::new(source).parse()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ErrorAssertion<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("error:")?;
|
||||
if let Some(column) = self.column {
|
||||
write!(f, " {column}")?;
|
||||
}
|
||||
if let Some(rule) = self.rule {
|
||||
write!(f, " [{rule}]")?;
|
||||
}
|
||||
if let Some(message) = self.message_contains {
|
||||
write!(f, r#" "{message}""#)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A parser to convert a string into a [`ErrorAssertion`].
|
||||
#[derive(Debug, Clone)]
|
||||
struct ErrorAssertionParser<'a> {
|
||||
cursor: Cursor<'a>,
|
||||
|
||||
/// string slice representing all characters *after* the `# error:` prefix.
|
||||
comment_source: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> ErrorAssertionParser<'a> {
|
||||
fn new(comment: &'a str) -> Self {
|
||||
Self {
|
||||
cursor: Cursor::new(comment),
|
||||
comment_source: comment,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the current offset of the cursor within the source code.
|
||||
fn offset(&self) -> TextSize {
|
||||
self.comment_source.text_len() - self.cursor.text_len()
|
||||
}
|
||||
|
||||
/// Consume characters in the assertion comment until we find a non-whitespace character
|
||||
fn skip_whitespace(&mut self) {
|
||||
self.cursor.eat_while(char::is_whitespace);
|
||||
}
|
||||
|
||||
/// Attempt to parse the assertion comment into a [`ErrorAssertion`].
|
||||
fn parse(mut self) -> Result<ErrorAssertion<'a>, ErrorAssertionParseError<'a>> {
|
||||
let mut column = None;
|
||||
let mut rule = None;
|
||||
|
||||
self.skip_whitespace();
|
||||
|
||||
while let Some(character) = self.cursor.bump() {
|
||||
match character {
|
||||
// column number
|
||||
'0'..='9' => {
|
||||
if column.is_some() {
|
||||
return Err(ErrorAssertionParseError::MultipleColumnNumbers);
|
||||
}
|
||||
if rule.is_some() {
|
||||
return Err(ErrorAssertionParseError::ColumnNumberAfterRuleCode);
|
||||
}
|
||||
let offset = self.offset() - TextSize::new(1);
|
||||
self.cursor.eat_while(|c| !c.is_whitespace());
|
||||
let column_str = &self.comment_source[TextRange::new(offset, self.offset())];
|
||||
column = OneIndexed::from_str(column_str)
|
||||
.map(Some)
|
||||
.map_err(|e| ErrorAssertionParseError::BadColumnNumber(column_str, e))?;
|
||||
}
|
||||
|
||||
// rule code
|
||||
'[' => {
|
||||
if rule.is_some() {
|
||||
return Err(ErrorAssertionParseError::MultipleRuleCodes);
|
||||
}
|
||||
let offset = self.offset();
|
||||
self.cursor.eat_while(|c| c != ']');
|
||||
if self.cursor.is_eof() {
|
||||
return Err(ErrorAssertionParseError::UnclosedRuleCode);
|
||||
}
|
||||
rule = Some(self.comment_source[TextRange::new(offset, self.offset())].trim());
|
||||
self.cursor.bump();
|
||||
}
|
||||
|
||||
// message text
|
||||
'"' => {
|
||||
let comment_source = self.comment_source.trim();
|
||||
return if comment_source.ends_with('"') {
|
||||
let rest =
|
||||
&comment_source[self.offset().to_usize()..comment_source.len() - 1];
|
||||
Ok(ErrorAssertion {
|
||||
rule,
|
||||
column,
|
||||
message_contains: Some(rest),
|
||||
})
|
||||
} else {
|
||||
Err(ErrorAssertionParseError::UnclosedMessage)
|
||||
};
|
||||
}
|
||||
|
||||
// Some other assumptions we make don't hold true if we hit this branch:
|
||||
'\n' | '\r' => {
|
||||
unreachable!("Assertion comments should never contain newlines")
|
||||
}
|
||||
|
||||
// something else (bad!)...
|
||||
unexpected => {
|
||||
return Err(ErrorAssertionParseError::UnexpectedCharacter {
|
||||
character: unexpected,
|
||||
offset: self.offset().to_usize(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.skip_whitespace();
|
||||
}
|
||||
|
||||
if rule.is_some() {
|
||||
Ok(ErrorAssertion {
|
||||
rule,
|
||||
column,
|
||||
message_contains: None,
|
||||
})
|
||||
} else {
|
||||
Err(ErrorAssertionParseError::NoRuleOrMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumeration of ways in which parsing an assertion comment can fail.
|
||||
///
|
||||
/// The assertion comment could be either a "revealed" assertion or an "error" assertion.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum PragmaParseError<'a> {
|
||||
#[error("Must specify which type should be revealed")]
|
||||
EmptyRevealTypeAssertion,
|
||||
#[error("{0}")]
|
||||
ErrorAssertionParseError(ErrorAssertionParseError<'a>),
|
||||
}
|
||||
|
||||
/// Enumeration of ways in which parsing an *error* assertion comment can fail.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ErrorAssertionParseError<'a> {
|
||||
#[error("no rule or message text")]
|
||||
NoRuleOrMessage,
|
||||
#[error("bad column number `{0}`")]
|
||||
BadColumnNumber(&'a str, #[source] std::num::ParseIntError),
|
||||
#[error("column number must precede the rule code")]
|
||||
ColumnNumberAfterRuleCode,
|
||||
#[error("multiple column numbers in one assertion")]
|
||||
MultipleColumnNumbers,
|
||||
#[error("expected ']' to close rule code")]
|
||||
UnclosedRuleCode,
|
||||
#[error("cannot use multiple rule codes in one assertion")]
|
||||
MultipleRuleCodes,
|
||||
#[error("expected '\"' to be the final character in an assertion with an error message")]
|
||||
UnclosedMessage,
|
||||
#[error("unexpected character `{character}` at offset {offset} (relative to the `:` in the assertion comment)")]
|
||||
UnexpectedCharacter { character: char, offset: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use ruff_db::system::DbWithWritableSystem as _;
|
||||
use ruff_python_ast::PythonVersion;
|
||||
use ruff_python_trivia::textwrap::dedent;
|
||||
use ruff_source_file::OneIndexed;
|
||||
use ty_python_semantic::{Program, ProgramSettings, PythonPlatform, SearchPathSettings};
|
||||
|
||||
fn get_assertions(source: &str) -> InlineFileAssertions {
|
||||
let mut db = Db::setup();
|
||||
|
||||
let settings = ProgramSettings {
|
||||
python_version: PythonVersion::default(),
|
||||
python_platform: PythonPlatform::default(),
|
||||
search_paths: SearchPathSettings::new(Vec::new()),
|
||||
};
|
||||
match Program::try_get(&db) {
|
||||
Some(program) => program.update_from_settings(&mut db, settings),
|
||||
None => Program::from_settings(&db, settings).map(|_| ()),
|
||||
}
|
||||
.expect("Failed to update Program settings in TestDb");
|
||||
|
||||
db.write_file("/src/test.py", source).unwrap();
|
||||
let file = system_path_to_file(&db, "/src/test.py").unwrap();
|
||||
InlineFileAssertions::from_file(&db, file)
|
||||
}
|
||||
|
||||
fn as_vec(assertions: &InlineFileAssertions) -> Vec<LineAssertions> {
|
||||
assertions.into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ty_display() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
reveal_type(1) # revealed: Literal[1]
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert}"), "revealed: Literal[1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
x # error:
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert}"), "error: ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prior_line() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
# revealed: Literal[1]
|
||||
reveal_type(1)
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert}"), "revealed: Literal[1]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_prior_line() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
# revealed: Unbound
|
||||
# error: [unbound-name]
|
||||
reveal_type(x)
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(3));
|
||||
|
||||
let [assert1, assert2] = &line.assertions[..] else {
|
||||
panic!("expected two assertions");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert1}"), "revealed: Unbound");
|
||||
assert_eq!(format!("{assert2}"), "error: [unbound-name]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_mixed() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
# revealed: Unbound
|
||||
reveal_type(x) # error: [unbound-name]
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
|
||||
|
||||
let [assert1, assert2] = &line.assertions[..] else {
|
||||
panic!("expected two assertions");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert1}"), "revealed: Unbound");
|
||||
assert_eq!(format!("{assert2}"), "error: [unbound-name]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lines() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
r#"
|
||||
# error: [invalid-assignment]
|
||||
x: int = "foo"
|
||||
y # error: [unbound-name]
|
||||
"#,
|
||||
));
|
||||
|
||||
let [line1, line2] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected two lines");
|
||||
};
|
||||
|
||||
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(2));
|
||||
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(3));
|
||||
|
||||
let [UnparsedAssertion::Error(error1)] = &line1.assertions[..] else {
|
||||
panic!("expected one error assertion");
|
||||
};
|
||||
|
||||
let error1 = ErrorAssertion::from_str(error1).unwrap();
|
||||
|
||||
assert_eq!(error1.rule, Some("invalid-assignment"));
|
||||
|
||||
let [UnparsedAssertion::Error(error2)] = &line2.assertions[..] else {
|
||||
panic!("expected one error assertion");
|
||||
};
|
||||
|
||||
let error2 = ErrorAssertion::from_str(error2).unwrap();
|
||||
|
||||
assert_eq!(error2.rule, Some("unbound-name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lines_mixed_stack() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
r#"
|
||||
# error: [invalid-assignment]
|
||||
x: int = reveal_type("foo") # revealed: str
|
||||
y # error: [unbound-name]
|
||||
"#,
|
||||
));
|
||||
|
||||
let [line1, line2] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected two lines");
|
||||
};
|
||||
|
||||
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(2));
|
||||
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(3));
|
||||
|
||||
let [UnparsedAssertion::Error(error1), UnparsedAssertion::Revealed(expected_ty)] =
|
||||
&line1.assertions[..]
|
||||
else {
|
||||
panic!("expected one error assertion and one Revealed assertion");
|
||||
};
|
||||
|
||||
let error1 = ErrorAssertion::from_str(error1).unwrap();
|
||||
|
||||
assert_eq!(error1.rule, Some("invalid-assignment"));
|
||||
assert_eq!(expected_ty.trim(), "str");
|
||||
|
||||
let [UnparsedAssertion::Error(error2)] = &line2.assertions[..] else {
|
||||
panic!("expected one error assertion");
|
||||
};
|
||||
|
||||
let error2 = ErrorAssertion::from_str(error2).unwrap();
|
||||
|
||||
assert_eq!(error2.rule, Some("unbound-name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_with_rule() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
x # error: [unbound-name]
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert}"), "error: [unbound-name]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_with_rule_and_column() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
"
|
||||
x # error: 1 [unbound-name]
|
||||
",
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(1));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert}"), "error: 1 [unbound-name]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_with_rule_and_message() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
r#"
|
||||
# error: [unbound-name] "`x` is unbound"
|
||||
x
|
||||
"#,
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
format!("{assert}"),
|
||||
r#"error: [unbound-name] "`x` is unbound""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_with_message_and_column() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
r#"
|
||||
# error: 1 "`x` is unbound"
|
||||
x
|
||||
"#,
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(format!("{assert}"), r#"error: 1 "`x` is unbound""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_with_rule_and_message_and_column() {
|
||||
let assertions = get_assertions(&dedent(
|
||||
r#"
|
||||
# error: 1 [unbound-name] "`x` is unbound"
|
||||
x
|
||||
"#,
|
||||
));
|
||||
|
||||
let [line] = &as_vec(&assertions)[..] else {
|
||||
panic!("expected one line");
|
||||
};
|
||||
|
||||
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
|
||||
|
||||
let [assert] = &line.assertions[..] else {
|
||||
panic!("expected one assertion");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
format!("{assert}"),
|
||||
r#"error: 1 [unbound-name] "`x` is unbound""#
|
||||
);
|
||||
}
|
||||
}
|
104
crates/ty_test/src/config.rs
Normal file
104
crates/ty_test/src/config.rs
Normal file
|
@ -0,0 +1,104 @@
|
|||
//! TOML-deserializable ty configuration, similar to `ty.toml`, to be able to
|
||||
//! control some configuration options from Markdown files. For now, this supports the
|
||||
//! following limited structure:
|
||||
//!
|
||||
//! ```toml
|
||||
//! log = true # or log = "ty=WARN"
|
||||
//! [environment]
|
||||
//! python-version = "3.10"
|
||||
//! ```
|
||||
|
||||
use anyhow::Context;
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use ruff_python_ast::PythonVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ty_python_semantic::PythonPlatform;
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub(crate) struct MarkdownTestConfig {
|
||||
pub(crate) environment: Option<Environment>,
|
||||
|
||||
pub(crate) log: Option<Log>,
|
||||
|
||||
/// The [`ruff_db::system::System`] to use for tests.
|
||||
///
|
||||
/// Defaults to the case-sensitive [`ruff_db::system::InMemorySystem`].
|
||||
pub(crate) system: Option<SystemKind>,
|
||||
}
|
||||
|
||||
impl MarkdownTestConfig {
|
||||
pub(crate) fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||
toml::from_str(s).context("Error while parsing Markdown TOML config")
|
||||
}
|
||||
|
||||
pub(crate) fn python_version(&self) -> Option<PythonVersion> {
|
||||
self.environment.as_ref()?.python_version
|
||||
}
|
||||
|
||||
pub(crate) fn python_platform(&self) -> Option<PythonPlatform> {
|
||||
self.environment.as_ref()?.python_platform.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn typeshed(&self) -> Option<&SystemPath> {
|
||||
self.environment.as_ref()?.typeshed.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn extra_paths(&self) -> Option<&[SystemPathBuf]> {
|
||||
self.environment.as_ref()?.extra_paths.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn python(&self) -> Option<&SystemPath> {
|
||||
self.environment.as_ref()?.python.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default, Clone)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
pub(crate) struct Environment {
|
||||
/// Target Python version to assume when resolving types.
|
||||
pub(crate) python_version: Option<PythonVersion>,
|
||||
|
||||
/// Target platform to assume when resolving types.
|
||||
pub(crate) python_platform: Option<PythonPlatform>,
|
||||
|
||||
/// Path to a custom typeshed directory.
|
||||
pub(crate) typeshed: Option<SystemPathBuf>,
|
||||
|
||||
/// Additional search paths to consider when resolving modules.
|
||||
pub(crate) extra_paths: Option<Vec<SystemPathBuf>>,
|
||||
|
||||
/// Path to the Python installation from which ty resolves type information and third-party dependencies.
|
||||
///
|
||||
/// ty will search in the path's `site-packages` directories for type information and
|
||||
/// third-party imports.
|
||||
///
|
||||
/// This option is commonly used to specify the path to a virtual environment.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python: Option<SystemPathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum Log {
|
||||
/// Enable logging with tracing when `true`.
|
||||
Bool(bool),
|
||||
/// Enable logging and only show filters that match the given [env-filter](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html)
|
||||
Filter(String),
|
||||
}
|
||||
|
||||
/// The system to use for tests.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum SystemKind {
|
||||
/// Use an in-memory system with a case sensitive file system..
|
||||
///
|
||||
/// This is recommended for all tests because it's fast.
|
||||
#[default]
|
||||
InMemory,
|
||||
|
||||
/// Use the os system.
|
||||
///
|
||||
/// This system should only be used when testing system or OS specific behavior.
|
||||
Os,
|
||||
}
|
284
crates/ty_test/src/db.rs
Normal file
284
crates/ty_test/src/db.rs
Normal file
|
@ -0,0 +1,284 @@
|
|||
use camino::{Utf8Component, Utf8PathBuf};
|
||||
use ruff_db::files::{File, Files};
|
||||
use ruff_db::system::{
|
||||
CaseSensitivity, DbWithWritableSystem, InMemorySystem, OsSystem, System, SystemPath,
|
||||
SystemPathBuf, WritableSystem,
|
||||
};
|
||||
use ruff_db::vendored::VendoredFileSystem;
|
||||
use ruff_db::{Db as SourceDb, Upcast};
|
||||
use ruff_notebook::{Notebook, NotebookError};
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use ty_python_semantic::lint::{LintRegistry, RuleSelection};
|
||||
use ty_python_semantic::{default_lint_registry, Db as SemanticDb, Program};
|
||||
|
||||
#[salsa::db]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Db {
|
||||
storage: salsa::Storage<Self>,
|
||||
files: Files,
|
||||
system: MdtestSystem,
|
||||
vendored: VendoredFileSystem,
|
||||
rule_selection: Arc<RuleSelection>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub(crate) fn setup() -> Self {
|
||||
let rule_selection = RuleSelection::from_registry(default_lint_registry());
|
||||
|
||||
Self {
|
||||
system: MdtestSystem::in_memory(),
|
||||
storage: salsa::Storage::default(),
|
||||
vendored: ty_vendored::file_system().clone(),
|
||||
files: Files::default(),
|
||||
rule_selection: Arc::new(rule_selection),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn use_os_system_with_temp_dir(&mut self, cwd: SystemPathBuf, temp_dir: TempDir) {
|
||||
self.system.with_os(cwd, temp_dir);
|
||||
Files::sync_all(self);
|
||||
}
|
||||
|
||||
pub(crate) fn use_in_memory_system(&mut self) {
|
||||
self.system.with_in_memory();
|
||||
Files::sync_all(self);
|
||||
}
|
||||
|
||||
pub(crate) fn create_directory_all(&self, path: &SystemPath) -> ruff_db::system::Result<()> {
|
||||
self.system.create_directory_all(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[salsa::db]
|
||||
impl SourceDb for Db {
|
||||
fn vendored(&self) -> &VendoredFileSystem {
|
||||
&self.vendored
|
||||
}
|
||||
|
||||
fn system(&self) -> &dyn System {
|
||||
&self.system
|
||||
}
|
||||
|
||||
fn files(&self) -> &Files {
|
||||
&self.files
|
||||
}
|
||||
|
||||
fn python_version(&self) -> ruff_python_ast::PythonVersion {
|
||||
Program::get(self).python_version(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Upcast<dyn SourceDb> for Db {
|
||||
fn upcast(&self) -> &(dyn SourceDb + 'static) {
|
||||
self
|
||||
}
|
||||
fn upcast_mut(&mut self) -> &mut (dyn SourceDb + 'static) {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[salsa::db]
|
||||
impl SemanticDb for Db {
|
||||
fn is_file_open(&self, file: File) -> bool {
|
||||
!file.path(self).is_vendored_path()
|
||||
}
|
||||
|
||||
fn rule_selection(&self) -> Arc<RuleSelection> {
|
||||
self.rule_selection.clone()
|
||||
}
|
||||
|
||||
fn lint_registry(&self) -> &LintRegistry {
|
||||
default_lint_registry()
|
||||
}
|
||||
}
|
||||
|
||||
#[salsa::db]
|
||||
impl salsa::Database for Db {
|
||||
fn salsa_event(&self, event: &dyn Fn() -> salsa::Event) {
|
||||
let event = event();
|
||||
tracing::trace!("event: {:?}", event);
|
||||
}
|
||||
}
|
||||
|
||||
impl DbWithWritableSystem for Db {
|
||||
type System = MdtestSystem;
|
||||
fn writable_system(&self) -> &Self::System {
|
||||
&self.system
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MdtestSystem(Arc<MdtestSystemInner>);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MdtestSystemInner {
|
||||
InMemory(InMemorySystem),
|
||||
Os {
|
||||
os_system: OsSystem,
|
||||
_temp_dir: TempDir,
|
||||
},
|
||||
}
|
||||
|
||||
impl MdtestSystem {
|
||||
fn in_memory() -> Self {
|
||||
Self(Arc::new(MdtestSystemInner::InMemory(
|
||||
InMemorySystem::default(),
|
||||
)))
|
||||
}
|
||||
|
||||
fn as_system(&self) -> &dyn WritableSystem {
|
||||
match &*self.0 {
|
||||
MdtestSystemInner::InMemory(system) => system,
|
||||
MdtestSystemInner::Os { os_system, .. } => os_system,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_os(&mut self, cwd: SystemPathBuf, temp_dir: TempDir) {
|
||||
self.0 = Arc::new(MdtestSystemInner::Os {
|
||||
os_system: OsSystem::new(cwd),
|
||||
_temp_dir: temp_dir,
|
||||
});
|
||||
}
|
||||
|
||||
fn with_in_memory(&mut self) {
|
||||
if let MdtestSystemInner::InMemory(in_memory) = &*self.0 {
|
||||
in_memory.fs().remove_all();
|
||||
} else {
|
||||
self.0 = Arc::new(MdtestSystemInner::InMemory(InMemorySystem::default()));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path<'a>(&self, path: &'a SystemPath) -> Cow<'a, SystemPath> {
|
||||
match &*self.0 {
|
||||
MdtestSystemInner::InMemory(_) => Cow::Borrowed(path),
|
||||
MdtestSystemInner::Os { os_system, .. } => {
|
||||
// Make all paths relative to the current directory
|
||||
// to avoid writing or reading from outside the temp directory.
|
||||
let without_root: Utf8PathBuf = path
|
||||
.components()
|
||||
.skip_while(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Utf8Component::RootDir | Utf8Component::Prefix(..)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Cow::Owned(os_system.current_directory().join(&without_root))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl System for MdtestSystem {
|
||||
fn path_metadata(
|
||||
&self,
|
||||
path: &SystemPath,
|
||||
) -> ruff_db::system::Result<ruff_db::system::Metadata> {
|
||||
self.as_system().path_metadata(&self.normalize_path(path))
|
||||
}
|
||||
|
||||
fn canonicalize_path(&self, path: &SystemPath) -> ruff_db::system::Result<SystemPathBuf> {
|
||||
let canonicalized = self
|
||||
.as_system()
|
||||
.canonicalize_path(&self.normalize_path(path))?;
|
||||
|
||||
if let MdtestSystemInner::Os { os_system, .. } = &*self.0 {
|
||||
// Make the path relative to the current directory
|
||||
Ok(canonicalized
|
||||
.strip_prefix(os_system.current_directory())
|
||||
.unwrap()
|
||||
.to_owned())
|
||||
} else {
|
||||
Ok(canonicalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_to_string(&self, path: &SystemPath) -> ruff_db::system::Result<String> {
|
||||
self.as_system().read_to_string(&self.normalize_path(path))
|
||||
}
|
||||
|
||||
fn read_to_notebook(&self, path: &SystemPath) -> Result<Notebook, NotebookError> {
|
||||
self.as_system()
|
||||
.read_to_notebook(&self.normalize_path(path))
|
||||
}
|
||||
|
||||
fn read_virtual_path_to_string(
|
||||
&self,
|
||||
path: &ruff_db::system::SystemVirtualPath,
|
||||
) -> ruff_db::system::Result<String> {
|
||||
self.as_system().read_virtual_path_to_string(path)
|
||||
}
|
||||
|
||||
fn read_virtual_path_to_notebook(
|
||||
&self,
|
||||
path: &ruff_db::system::SystemVirtualPath,
|
||||
) -> Result<Notebook, NotebookError> {
|
||||
self.as_system().read_virtual_path_to_notebook(path)
|
||||
}
|
||||
|
||||
fn path_exists_case_sensitive(&self, path: &SystemPath, prefix: &SystemPath) -> bool {
|
||||
self.as_system()
|
||||
.path_exists_case_sensitive(&self.normalize_path(path), &self.normalize_path(prefix))
|
||||
}
|
||||
|
||||
fn case_sensitivity(&self) -> CaseSensitivity {
|
||||
self.as_system().case_sensitivity()
|
||||
}
|
||||
|
||||
fn current_directory(&self) -> &SystemPath {
|
||||
self.as_system().current_directory()
|
||||
}
|
||||
|
||||
fn user_config_directory(&self) -> Option<SystemPathBuf> {
|
||||
self.as_system().user_config_directory()
|
||||
}
|
||||
|
||||
fn read_directory<'a>(
|
||||
&'a self,
|
||||
path: &SystemPath,
|
||||
) -> ruff_db::system::Result<
|
||||
Box<dyn Iterator<Item = ruff_db::system::Result<ruff_db::system::DirectoryEntry>> + 'a>,
|
||||
> {
|
||||
self.as_system().read_directory(&self.normalize_path(path))
|
||||
}
|
||||
|
||||
fn walk_directory(
|
||||
&self,
|
||||
path: &SystemPath,
|
||||
) -> ruff_db::system::walk_directory::WalkDirectoryBuilder {
|
||||
self.as_system().walk_directory(&self.normalize_path(path))
|
||||
}
|
||||
|
||||
fn glob(
|
||||
&self,
|
||||
pattern: &str,
|
||||
) -> Result<
|
||||
Box<dyn Iterator<Item = Result<SystemPathBuf, ruff_db::system::GlobError>>>,
|
||||
ruff_db::system::PatternError,
|
||||
> {
|
||||
self.as_system()
|
||||
.glob(self.normalize_path(SystemPath::new(pattern)).as_str())
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl WritableSystem for MdtestSystem {
|
||||
fn write_file(&self, path: &SystemPath, content: &str) -> ruff_db::system::Result<()> {
|
||||
self.as_system()
|
||||
.write_file(&self.normalize_path(path), content)
|
||||
}
|
||||
|
||||
fn create_directory_all(&self, path: &SystemPath) -> ruff_db::system::Result<()> {
|
||||
self.as_system()
|
||||
.create_directory_all(&self.normalize_path(path))
|
||||
}
|
||||
}
|
190
crates/ty_test/src/diagnostic.rs
Normal file
190
crates/ty_test/src/diagnostic.rs
Normal file
|
@ -0,0 +1,190 @@
|
|||
//! Sort and group diagnostics by line number, so they can be correlated with assertions.
|
||||
//!
|
||||
//! We don't assume that we will get the diagnostics in source order.
|
||||
|
||||
use ruff_db::diagnostic::Diagnostic;
|
||||
use ruff_source_file::{LineIndex, OneIndexed};
|
||||
use std::ops::{Deref, Range};
|
||||
|
||||
/// All diagnostics for one embedded Python file, sorted and grouped by start line number.
|
||||
///
|
||||
/// The diagnostics are kept in a flat vector, sorted by line number. A separate vector of
|
||||
/// [`LineDiagnosticRange`] has one entry for each contiguous slice of the diagnostics vector
|
||||
/// containing diagnostics which all start on the same line.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SortedDiagnostics<'a> {
|
||||
diagnostics: Vec<&'a Diagnostic>,
|
||||
line_ranges: Vec<LineDiagnosticRange>,
|
||||
}
|
||||
|
||||
impl<'a> SortedDiagnostics<'a> {
|
||||
pub(crate) fn new(
|
||||
diagnostics: impl IntoIterator<Item = &'a Diagnostic>,
|
||||
line_index: &LineIndex,
|
||||
) -> Self {
|
||||
let mut diagnostics: Vec<_> = diagnostics
|
||||
.into_iter()
|
||||
.map(|diagnostic| DiagnosticWithLine {
|
||||
line_number: diagnostic
|
||||
.primary_span()
|
||||
.and_then(|span| span.range())
|
||||
.map_or(OneIndexed::from_zero_indexed(0), |range| {
|
||||
line_index.line_index(range.start())
|
||||
}),
|
||||
diagnostic,
|
||||
})
|
||||
.collect();
|
||||
diagnostics.sort_unstable_by_key(|diagnostic_with_line| diagnostic_with_line.line_number);
|
||||
|
||||
let mut diags = Self {
|
||||
diagnostics: Vec::with_capacity(diagnostics.len()),
|
||||
line_ranges: vec![],
|
||||
};
|
||||
|
||||
let mut current_line_number = None;
|
||||
let mut start = 0;
|
||||
for DiagnosticWithLine {
|
||||
line_number,
|
||||
diagnostic,
|
||||
} in diagnostics
|
||||
{
|
||||
match current_line_number {
|
||||
None => {
|
||||
current_line_number = Some(line_number);
|
||||
}
|
||||
Some(current) => {
|
||||
if line_number != current {
|
||||
let end = diags.diagnostics.len();
|
||||
diags.line_ranges.push(LineDiagnosticRange {
|
||||
line_number: current,
|
||||
diagnostic_index_range: start..end,
|
||||
});
|
||||
start = end;
|
||||
current_line_number = Some(line_number);
|
||||
}
|
||||
}
|
||||
}
|
||||
diags.diagnostics.push(diagnostic);
|
||||
}
|
||||
if let Some(line_number) = current_line_number {
|
||||
diags.line_ranges.push(LineDiagnosticRange {
|
||||
line_number,
|
||||
diagnostic_index_range: start..diags.diagnostics.len(),
|
||||
});
|
||||
}
|
||||
|
||||
diags
|
||||
}
|
||||
|
||||
pub(crate) fn iter_lines(&self) -> LineDiagnosticsIterator<'_> {
|
||||
LineDiagnosticsIterator {
|
||||
diagnostics: self.diagnostics.as_slice(),
|
||||
inner: self.line_ranges.iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Range delineating diagnostics in [`SortedDiagnostics`] that begin on a single line.
|
||||
#[derive(Debug)]
|
||||
struct LineDiagnosticRange {
|
||||
line_number: OneIndexed,
|
||||
diagnostic_index_range: Range<usize>,
|
||||
}
|
||||
|
||||
/// Iterator to group sorted diagnostics by line.
|
||||
pub(crate) struct LineDiagnosticsIterator<'a> {
|
||||
diagnostics: &'a [&'a Diagnostic],
|
||||
inner: std::slice::Iter<'a, LineDiagnosticRange>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for LineDiagnosticsIterator<'a> {
|
||||
type Item = LineDiagnostics<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let LineDiagnosticRange {
|
||||
line_number,
|
||||
diagnostic_index_range,
|
||||
} = self.inner.next()?;
|
||||
Some(LineDiagnostics {
|
||||
line_number: *line_number,
|
||||
diagnostics: &self.diagnostics[diagnostic_index_range.clone()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::iter::FusedIterator for LineDiagnosticsIterator<'_> {}
|
||||
|
||||
/// All diagnostics that start on a single line of source code in one embedded Python file.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LineDiagnostics<'a> {
|
||||
/// Line number on which these diagnostics start.
|
||||
pub(crate) line_number: OneIndexed,
|
||||
|
||||
/// Diagnostics starting on this line.
|
||||
pub(crate) diagnostics: &'a [&'a Diagnostic],
|
||||
}
|
||||
|
||||
impl<'a> Deref for LineDiagnostics<'a> {
|
||||
type Target = [&'a Diagnostic];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DiagnosticWithLine<'a> {
|
||||
line_number: OneIndexed,
|
||||
diagnostic: &'a Diagnostic,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::Db;
|
||||
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span};
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use ruff_db::source::line_index;
|
||||
use ruff_db::system::DbWithWritableSystem as _;
|
||||
use ruff_source_file::OneIndexed;
|
||||
use ruff_text_size::{TextRange, TextSize};
|
||||
|
||||
#[test]
|
||||
fn sort_and_group() {
|
||||
let mut db = Db::setup();
|
||||
db.write_file("/src/test.py", "one\ntwo\n").unwrap();
|
||||
let file = system_path_to_file(&db, "/src/test.py").unwrap();
|
||||
let lines = line_index(&db, file);
|
||||
|
||||
let ranges = [
|
||||
TextRange::new(TextSize::new(0), TextSize::new(1)),
|
||||
TextRange::new(TextSize::new(5), TextSize::new(10)),
|
||||
TextRange::new(TextSize::new(1), TextSize::new(7)),
|
||||
];
|
||||
|
||||
let diagnostics: Vec<_> = ranges
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
let mut diag = Diagnostic::new(
|
||||
DiagnosticId::Lint(LintName::of("dummy")),
|
||||
Severity::Error,
|
||||
"dummy",
|
||||
);
|
||||
let span = Span::from(file).with_range(range);
|
||||
diag.annotate(Annotation::primary(span));
|
||||
diag
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sorted = super::SortedDiagnostics::new(diagnostics.iter(), &lines);
|
||||
let grouped = sorted.iter_lines().collect::<Vec<_>>();
|
||||
|
||||
let [line1, line2] = &grouped[..] else {
|
||||
panic!("expected two lines");
|
||||
};
|
||||
|
||||
assert_eq!(line1.line_number, OneIndexed::from_zero_indexed(0));
|
||||
assert_eq!(line1.diagnostics.len(), 2);
|
||||
assert_eq!(line2.line_number, OneIndexed::from_zero_indexed(1));
|
||||
assert_eq!(line2.diagnostics.len(), 1);
|
||||
}
|
||||
}
|
470
crates/ty_test/src/lib.rs
Normal file
470
crates/ty_test/src/lib.rs
Normal file
|
@ -0,0 +1,470 @@
|
|||
use crate::config::Log;
|
||||
use crate::parser::{BacktickOffsets, EmbeddedFileSourceMap};
|
||||
use camino::Utf8Path;
|
||||
use colored::Colorize;
|
||||
use config::SystemKind;
|
||||
use parser as test_parser;
|
||||
use ruff_db::diagnostic::{
|
||||
create_parse_diagnostic, create_unsupported_syntax_diagnostic, Diagnostic,
|
||||
DisplayDiagnosticConfig,
|
||||
};
|
||||
use ruff_db::files::{system_path_to_file, File};
|
||||
use ruff_db::panic::catch_unwind;
|
||||
use ruff_db::parsed::parsed_module;
|
||||
use ruff_db::system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf};
|
||||
use ruff_db::testing::{setup_logging, setup_logging_with_filter};
|
||||
use ruff_source_file::{LineIndex, OneIndexed};
|
||||
use std::backtrace::BacktraceStatus;
|
||||
use std::fmt::Write;
|
||||
use ty_python_semantic::types::check_types;
|
||||
use ty_python_semantic::{
|
||||
Program, ProgramSettings, PythonPath, PythonPlatform, SearchPathSettings, SysPrefixPathOrigin,
|
||||
};
|
||||
|
||||
mod assertion;
|
||||
mod config;
|
||||
mod db;
|
||||
mod diagnostic;
|
||||
mod matcher;
|
||||
mod parser;
|
||||
|
||||
const MDTEST_TEST_FILTER: &str = "MDTEST_TEST_FILTER";
|
||||
|
||||
/// Run `path` as a markdown test suite with given `title`.
|
||||
///
|
||||
/// Panic on test failure, and print failure details.
|
||||
#[allow(clippy::print_stdout)]
|
||||
pub fn run(
|
||||
absolute_fixture_path: &Utf8Path,
|
||||
relative_fixture_path: &Utf8Path,
|
||||
snapshot_path: &Utf8Path,
|
||||
short_title: &str,
|
||||
test_name: &str,
|
||||
output_format: OutputFormat,
|
||||
) {
|
||||
let source = std::fs::read_to_string(absolute_fixture_path).unwrap();
|
||||
let suite = match test_parser::parse(short_title, &source) {
|
||||
Ok(suite) => suite,
|
||||
Err(err) => {
|
||||
panic!("Error parsing `{absolute_fixture_path}`: {err:?}")
|
||||
}
|
||||
};
|
||||
|
||||
let mut db = db::Db::setup();
|
||||
|
||||
let filter = std::env::var(MDTEST_TEST_FILTER).ok();
|
||||
let mut any_failures = false;
|
||||
for test in suite.tests() {
|
||||
if filter.as_ref().is_some_and(|f| !test.name().contains(f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _tracing = test.configuration().log.as_ref().and_then(|log| match log {
|
||||
Log::Bool(enabled) => enabled.then(setup_logging),
|
||||
Log::Filter(filter) => setup_logging_with_filter(filter),
|
||||
});
|
||||
|
||||
if let Err(failures) = run_test(&mut db, relative_fixture_path, snapshot_path, &test) {
|
||||
any_failures = true;
|
||||
|
||||
if output_format.is_cli() {
|
||||
println!("\n{}\n", test.name().bold().underline());
|
||||
}
|
||||
|
||||
let md_index = LineIndex::from_source_text(&source);
|
||||
|
||||
for test_failures in failures {
|
||||
let source_map =
|
||||
EmbeddedFileSourceMap::new(&md_index, test_failures.backtick_offsets);
|
||||
|
||||
for (relative_line_number, failures) in test_failures.by_line.iter() {
|
||||
let absolute_line_number =
|
||||
source_map.to_absolute_line_number(relative_line_number);
|
||||
|
||||
for failure in failures {
|
||||
match output_format {
|
||||
OutputFormat::Cli => {
|
||||
let line_info =
|
||||
format!("{relative_fixture_path}:{absolute_line_number}")
|
||||
.cyan();
|
||||
println!(" {line_info} {failure}");
|
||||
}
|
||||
OutputFormat::GitHub => println!(
|
||||
"::error file={absolute_fixture_path},line={absolute_line_number}::{failure}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let escaped_test_name = test.name().replace('\'', "\\'");
|
||||
|
||||
if output_format.is_cli() {
|
||||
println!(
|
||||
"\nTo rerun this specific test, set the environment variable: {MDTEST_TEST_FILTER}='{escaped_test_name}'",
|
||||
);
|
||||
println!(
|
||||
"{MDTEST_TEST_FILTER}='{escaped_test_name}' cargo test -p ty_python_semantic --test mdtest -- {test_name}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n{}\n", "-".repeat(50));
|
||||
|
||||
assert!(!any_failures, "Some tests failed.");
|
||||
}
|
||||
|
||||
/// Defines the format in which mdtest should print an error to the terminal
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutputFormat {
|
||||
/// The format `cargo test` should use by default.
|
||||
Cli,
|
||||
/// A format that will provide annotations from GitHub Actions
|
||||
/// if mdtest fails on a PR.
|
||||
/// See <https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-error-message>
|
||||
GitHub,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
const fn is_cli(self) -> bool {
|
||||
matches!(self, OutputFormat::Cli)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_test(
|
||||
db: &mut db::Db,
|
||||
relative_fixture_path: &Utf8Path,
|
||||
snapshot_path: &Utf8Path,
|
||||
test: &parser::MarkdownTest,
|
||||
) -> Result<(), Failures> {
|
||||
// Initialize the system and remove all files and directories to reset the system to a clean state.
|
||||
match test.configuration().system.unwrap_or_default() {
|
||||
SystemKind::InMemory => {
|
||||
db.use_in_memory_system();
|
||||
}
|
||||
SystemKind::Os => {
|
||||
let dir = tempfile::TempDir::new().expect("Creating a temporary directory to succeed");
|
||||
let root_path = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("Canonicalizing to succeed");
|
||||
let root_path = SystemPathBuf::from_path_buf(root_path)
|
||||
.expect("Temp directory to be a valid UTF8 path")
|
||||
.simplified()
|
||||
.to_path_buf();
|
||||
|
||||
db.use_os_system_with_temp_dir(root_path, dir);
|
||||
}
|
||||
}
|
||||
|
||||
let project_root = SystemPathBuf::from("/src");
|
||||
db.create_directory_all(&project_root)
|
||||
.expect("Creating the project root to succeed");
|
||||
|
||||
let src_path = project_root.clone();
|
||||
let custom_typeshed_path = test.configuration().typeshed();
|
||||
let python_path = test.configuration().python();
|
||||
let python_version = test.configuration().python_version().unwrap_or_default();
|
||||
|
||||
let mut typeshed_files = vec![];
|
||||
let mut has_custom_versions_file = false;
|
||||
let mut has_custom_pyvenv_cfg_file = false;
|
||||
|
||||
let test_files: Vec<_> = test
|
||||
.files()
|
||||
.filter_map(|embedded| {
|
||||
if embedded.lang == "ignore" {
|
||||
return None;
|
||||
}
|
||||
|
||||
assert!(
|
||||
matches!(embedded.lang, "py" | "pyi" | "python" | "text" | "cfg"),
|
||||
"Supported file types are: py (or python), pyi, text, cfg and ignore"
|
||||
);
|
||||
|
||||
let mut full_path = embedded.full_path(&project_root);
|
||||
|
||||
if let Some(typeshed_path) = custom_typeshed_path {
|
||||
if let Ok(relative_path) = full_path.strip_prefix(typeshed_path.join("stdlib")) {
|
||||
if relative_path.as_str() == "VERSIONS" {
|
||||
has_custom_versions_file = true;
|
||||
} else if relative_path.extension().is_some_and(|ext| ext == "pyi") {
|
||||
typeshed_files.push(relative_path.to_path_buf());
|
||||
}
|
||||
}
|
||||
} else if let Some(python_path) = python_path {
|
||||
if let Ok(relative_path) = full_path.strip_prefix(python_path) {
|
||||
if relative_path.as_str() == "pyvenv.cfg" {
|
||||
has_custom_pyvenv_cfg_file = true;
|
||||
} else {
|
||||
let mut new_path = SystemPathBuf::new();
|
||||
for component in full_path.components() {
|
||||
let component = component.as_str();
|
||||
if component == "<path-to-site-packages>" {
|
||||
if cfg!(target_os = "windows") {
|
||||
new_path.push("Lib");
|
||||
new_path.push("site-packages");
|
||||
} else {
|
||||
new_path.push("lib");
|
||||
new_path.push(format!("python{python_version}"));
|
||||
new_path.push("site-packages");
|
||||
}
|
||||
} else {
|
||||
new_path.push(component);
|
||||
}
|
||||
}
|
||||
full_path = new_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.write_file(&full_path, &embedded.code).unwrap();
|
||||
|
||||
if !(full_path.starts_with(&src_path) && matches!(embedded.lang, "py" | "pyi")) {
|
||||
// These files need to be written to the file system (above), but we don't run any checks on them.
|
||||
return None;
|
||||
}
|
||||
|
||||
let file = system_path_to_file(db, full_path).unwrap();
|
||||
|
||||
Some(TestFile {
|
||||
file,
|
||||
backtick_offsets: embedded.backtick_offsets.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create a custom typeshed `VERSIONS` file if none was provided.
|
||||
if let Some(typeshed_path) = custom_typeshed_path {
|
||||
if !has_custom_versions_file {
|
||||
let versions_file = typeshed_path.join("stdlib/VERSIONS");
|
||||
let contents = typeshed_files
|
||||
.iter()
|
||||
.fold(String::new(), |mut content, path| {
|
||||
// This is intentionally kept simple:
|
||||
let module_name = path
|
||||
.as_str()
|
||||
.trim_end_matches(".pyi")
|
||||
.trim_end_matches("/__init__")
|
||||
.replace('/', ".");
|
||||
let _ = writeln!(content, "{module_name}: 3.8-");
|
||||
content
|
||||
});
|
||||
db.write_file(&versions_file, contents).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(python_path) = python_path {
|
||||
if !has_custom_pyvenv_cfg_file {
|
||||
let pyvenv_cfg_file = python_path.join("pyvenv.cfg");
|
||||
let home_directory = SystemPathBuf::from(format!("/Python{python_version}"));
|
||||
db.create_directory_all(&home_directory).unwrap();
|
||||
db.write_file(&pyvenv_cfg_file, format!("home = {home_directory}"))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let configuration = test.configuration();
|
||||
|
||||
let settings = ProgramSettings {
|
||||
python_version,
|
||||
python_platform: configuration
|
||||
.python_platform()
|
||||
.unwrap_or(PythonPlatform::Identifier("linux".to_string())),
|
||||
search_paths: SearchPathSettings {
|
||||
src_roots: vec![src_path],
|
||||
extra_paths: configuration.extra_paths().unwrap_or_default().to_vec(),
|
||||
custom_typeshed: custom_typeshed_path.map(SystemPath::to_path_buf),
|
||||
python_path: configuration
|
||||
.python()
|
||||
.map(|sys_prefix| {
|
||||
PythonPath::SysPrefix(
|
||||
sys_prefix.to_path_buf(),
|
||||
SysPrefixPathOrigin::PythonCliFlag,
|
||||
)
|
||||
})
|
||||
.unwrap_or(PythonPath::KnownSitePackages(vec![])),
|
||||
},
|
||||
};
|
||||
|
||||
match Program::try_get(db) {
|
||||
Some(program) => program.update_from_settings(db, settings),
|
||||
None => Program::from_settings(db, settings).map(|_| ()),
|
||||
}
|
||||
.expect("Failed to update Program settings in TestDb");
|
||||
|
||||
// When snapshot testing is enabled, this is populated with
|
||||
// all diagnostics. Otherwise it remains empty.
|
||||
let mut snapshot_diagnostics = vec![];
|
||||
|
||||
let failures: Failures = test_files
|
||||
.into_iter()
|
||||
.filter_map(|test_file| {
|
||||
let parsed = parsed_module(db, test_file.file);
|
||||
|
||||
let mut diagnostics: Vec<Diagnostic> = parsed
|
||||
.errors()
|
||||
.iter()
|
||||
.map(|error| create_parse_diagnostic(test_file.file, error))
|
||||
.collect();
|
||||
|
||||
diagnostics.extend(
|
||||
parsed
|
||||
.unsupported_syntax_errors()
|
||||
.iter()
|
||||
.map(|error| create_unsupported_syntax_diagnostic(test_file.file, error)),
|
||||
);
|
||||
|
||||
let type_diagnostics = match catch_unwind(|| check_types(db, test_file.file)) {
|
||||
Ok(type_diagnostics) => type_diagnostics,
|
||||
Err(info) => {
|
||||
let mut by_line = matcher::FailuresByLine::default();
|
||||
let mut messages = vec![];
|
||||
match info.location {
|
||||
Some(location) => messages.push(format!("panicked at {location}")),
|
||||
None => messages.push("panicked at unknown location".to_string()),
|
||||
}
|
||||
match info.payload.as_str() {
|
||||
Some(message) => messages.push(message.to_string()),
|
||||
// Mimic the default panic hook's rendering of the panic payload if it's
|
||||
// not a string.
|
||||
None => messages.push("Box<dyn Any>".to_string()),
|
||||
}
|
||||
if let Some(backtrace) = info.backtrace {
|
||||
match backtrace.status() {
|
||||
BacktraceStatus::Disabled => {
|
||||
let msg = "run with `RUST_BACKTRACE=1` environment variable to display a backtrace";
|
||||
messages.push(msg.to_string());
|
||||
}
|
||||
BacktraceStatus::Captured => {
|
||||
messages.extend(backtrace.to_string().split('\n').map(String::from));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(backtrace) = info.salsa_backtrace {
|
||||
salsa::attach(db, || {
|
||||
messages.extend(format!("{backtrace:#}").split('\n').map(String::from));
|
||||
});
|
||||
}
|
||||
|
||||
by_line.push(OneIndexed::from_zero_indexed(0), messages);
|
||||
return Some(FileFailures {
|
||||
backtick_offsets: test_file.backtick_offsets,
|
||||
by_line,
|
||||
});
|
||||
}
|
||||
};
|
||||
diagnostics.extend(type_diagnostics.into_iter().cloned());
|
||||
|
||||
let failure = match matcher::match_file(db, test_file.file, &diagnostics) {
|
||||
Ok(()) => None,
|
||||
Err(line_failures) => Some(FileFailures {
|
||||
backtick_offsets: test_file.backtick_offsets,
|
||||
by_line: line_failures,
|
||||
}),
|
||||
};
|
||||
if test.should_snapshot_diagnostics() {
|
||||
snapshot_diagnostics.extend(diagnostics);
|
||||
}
|
||||
failure
|
||||
})
|
||||
.collect();
|
||||
|
||||
if snapshot_diagnostics.is_empty() && test.should_snapshot_diagnostics() {
|
||||
panic!(
|
||||
"Test `{}` requested snapshotting diagnostics but it didn't produce any.",
|
||||
test.name()
|
||||
);
|
||||
} else if !snapshot_diagnostics.is_empty() {
|
||||
let snapshot =
|
||||
create_diagnostic_snapshot(db, relative_fixture_path, test, snapshot_diagnostics);
|
||||
let name = test.name().replace(' ', "_").replace(':', "__");
|
||||
insta::with_settings!(
|
||||
{
|
||||
snapshot_path => snapshot_path,
|
||||
input_file => name.clone(),
|
||||
filters => vec![(r"\\", "/")],
|
||||
prepend_module_to_snapshot => false,
|
||||
},
|
||||
{ insta::assert_snapshot!(name, snapshot) }
|
||||
);
|
||||
}
|
||||
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(failures)
|
||||
}
|
||||
}
|
||||
|
||||
type Failures = Vec<FileFailures>;
|
||||
|
||||
/// The failures for a single file in a test by line number.
|
||||
struct FileFailures {
|
||||
/// Positional information about the code block(s) to reconstruct absolute line numbers.
|
||||
backtick_offsets: Vec<BacktickOffsets>,
|
||||
|
||||
/// The failures by lines in the file.
|
||||
by_line: matcher::FailuresByLine,
|
||||
}
|
||||
|
||||
/// File in a test.
|
||||
struct TestFile {
|
||||
file: File,
|
||||
|
||||
/// Positional information about the code block(s) to reconstruct absolute line numbers.
|
||||
backtick_offsets: Vec<BacktickOffsets>,
|
||||
}
|
||||
|
||||
fn create_diagnostic_snapshot(
|
||||
db: &mut db::Db,
|
||||
relative_fixture_path: &Utf8Path,
|
||||
test: &parser::MarkdownTest,
|
||||
diagnostics: impl IntoIterator<Item = Diagnostic>,
|
||||
) -> String {
|
||||
let display_config = DisplayDiagnosticConfig::default().color(false);
|
||||
|
||||
let mut snapshot = String::new();
|
||||
writeln!(snapshot).unwrap();
|
||||
writeln!(snapshot, "---").unwrap();
|
||||
writeln!(snapshot, "mdtest name: {}", test.name()).unwrap();
|
||||
writeln!(snapshot, "mdtest path: {relative_fixture_path}").unwrap();
|
||||
writeln!(snapshot, "---").unwrap();
|
||||
writeln!(snapshot).unwrap();
|
||||
|
||||
writeln!(snapshot, "# Python source files").unwrap();
|
||||
writeln!(snapshot).unwrap();
|
||||
for file in test.files() {
|
||||
writeln!(snapshot, "## {}", file.relative_path()).unwrap();
|
||||
writeln!(snapshot).unwrap();
|
||||
// Note that we don't use ```py here because the line numbering
|
||||
// we add makes it invalid Python. This sacrifices syntax
|
||||
// highlighting when you look at the snapshot on GitHub,
|
||||
// but the line numbers are extremely useful for analyzing
|
||||
// snapshots. So we keep them.
|
||||
writeln!(snapshot, "```").unwrap();
|
||||
|
||||
let line_number_width = file.code.lines().count().to_string().len();
|
||||
for (i, line) in file.code.lines().enumerate() {
|
||||
let line_number = i + 1;
|
||||
writeln!(snapshot, "{line_number:>line_number_width$} | {line}").unwrap();
|
||||
}
|
||||
writeln!(snapshot, "```").unwrap();
|
||||
writeln!(snapshot).unwrap();
|
||||
}
|
||||
|
||||
writeln!(snapshot, "# Diagnostics").unwrap();
|
||||
writeln!(snapshot).unwrap();
|
||||
for (i, diag) in diagnostics.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
writeln!(snapshot).unwrap();
|
||||
}
|
||||
writeln!(snapshot, "```").unwrap();
|
||||
write!(snapshot, "{}", diag.display(db, &display_config)).unwrap();
|
||||
writeln!(snapshot, "```").unwrap();
|
||||
}
|
||||
snapshot
|
||||
}
|
1323
crates/ty_test/src/matcher.rs
Normal file
1323
crates/ty_test/src/matcher.rs
Normal file
File diff suppressed because it is too large
Load diff
1859
crates/ty_test/src/parser.rs
Normal file
1859
crates/ty_test/src/parser.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue